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, PositionReport},
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    /// The USDC collateral balance, and each perpetual's open orders and position.
283    ///
284    /// # Positions
285    ///
286    /// Every requested perpetual is listed. Its position is [`PositionReport::Open`] when
287    /// Hyperliquid reports a non-zero size for it, and [`PositionReport::Flat`] otherwise, since
288    /// the user state holds every open perpetual position. A size that does not parse is
289    /// [`PositionReport::Unreported`], with a warning.
290    async fn account_snapshot(
291        &self,
292        _assets: &[AssetNameExchange],
293        instruments: &[InstrumentNameExchange],
294    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
295        let address = self.wallet_h160();
296
297        // Fetch user state (balances + positions) and open orders concurrently
298        let (user_state, open_orders) = tokio::try_join!(
299            async {
300                self.info_client
301                    .user_state(address)
302                    .await
303                    .map_err(map_sdk_error)
304            },
305            open_orders(&self.info_client, address)
306        )?;
307
308        let now = Utc::now();
309
310        // Build balance from margin summary (USDC is the only collateral)
311        let account_value =
312            parse_decimal(&user_state.margin_summary.account_value, "account_value")
313                .unwrap_or(Decimal::ZERO);
314        let margin_used = parse_decimal(
315            &user_state.margin_summary.total_margin_used,
316            "total_margin_used",
317        )
318        .unwrap_or(Decimal::ZERO);
319
320        // Free balance can go negative during liquidation (margin_used > account_value).
321        // Clamp to zero since negative free balance has no meaningful interpretation.
322        let free_balance = (account_value - margin_used).max(Decimal::ZERO);
323        let balances = vec![AssetBalance::new(
324            AssetNameExchange::from(USDC_ASSET),
325            Balance::new(account_value, free_balance),
326            now,
327        )];
328
329        // Build instrument filter if provided
330        let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
331            None
332        } else {
333            let mut set = HashSet::with_capacity(instruments.len());
334            set.extend(instruments.iter().cloned());
335            Some(set)
336        };
337
338        let mut listing = OpenOrderListing::new(
339            &open_orders,
340            ExchangeId::HyperliquidPerp,
341            instruments,
342            |coin| Some(perp_coin_to_instrument(coin)),
343        );
344
345        // Build positions from asset_positions
346        let mut instrument_snapshots = Vec::new();
347        for asset_pos in user_state.asset_positions {
348            let pos = &asset_pos.position;
349            let instrument = perp_coin_to_instrument(&pos.coin);
350
351            if instrument_filter
352                .as_ref()
353                .is_some_and(|f| !f.contains(&instrument))
354            {
355                continue;
356            }
357
358            let position = perp_position_report(pos, now);
359
360            let (orders, orders_complete) = match listing.remove(&instrument) {
361                Some(listed) => (listed.orders, listed.orders_complete),
362                // The listing has an entry for every requested instrument, so this is an
363                // unfiltered read with no open order on the instrument.
364                None => (Vec::new(), true),
365            };
366
367            instrument_snapshots.push(InstrumentAccountSnapshot {
368                instrument,
369                orders,
370                orders_complete,
371                position,
372                isolated: None,
373            });
374        }
375
376        // Every other instrument listed: open orders but no position, or requested with neither.
377        // `asset_positions` holds every open perp position, so these are flat.
378        instrument_snapshots.extend(listing.into_snapshots().map(|mut snapshot| {
379            snapshot.position = PositionReport::Flat;
380            snapshot
381        }));
382
383        Ok(AccountSnapshot {
384            exchange: ExchangeId::HyperliquidPerp,
385            balances,
386            instruments: instrument_snapshots,
387        })
388    }
389
390    /// Returns a live stream of account events (fills, order updates).
391    ///
392    /// # Instrument filtering
393    ///
394    /// The `instruments` parameter is **ignored** — Hyperliquid's WebSocket API does not
395    /// support per-instrument subscriptions for user events. All fills and order updates
396    /// across all instruments are delivered. Consumers requiring instrument filtering
397    /// must filter client-side.
398    ///
399    /// # Task lifecycle
400    ///
401    /// Spawns two background tasks (fills, orders) that are automatically cancelled
402    /// when the returned stream is dropped. The `ws_client` is held by the orders task;
403    /// when cancelled, both tasks exit and the WebSocket connection closes.
404    async fn account_stream(
405        &self,
406        _assets: &[AssetNameExchange],
407        _instruments: &[InstrumentNameExchange],
408    ) -> Result<Self::AccountStream, UnindexedClientError> {
409        let user = self.wallet_h160();
410        let base_url = self.base_url();
411
412        // Create a dedicated InfoClient for WebSocket streaming.
413        // Using with_reconnect() enables SDK-managed reconnection.
414        let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
415            .await
416            .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
417
418        // Create channels for subscriptions
419        let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
420        let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
421
422        // Subscribe to user fills
423        ws_client
424            .subscribe(Subscription::UserFills { user }, fills_tx)
425            .await
426            .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
427
428        // Subscribe to order updates
429        ws_client
430            .subscribe(Subscription::OrderUpdates { user }, orders_tx)
431            .await
432            .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
433
434        info!(%user, "Subscribed to Hyperliquid account stream");
435
436        // Create output channel for merged events
437        let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
438
439        // CancellationToken ensures tasks exit when stream is dropped
440        let cancel_token = CancellationToken::new();
441
442        // Both tasks share `event_tx` and both observe `recv() -> None` when the SDK gives up on the
443        // socket, so guard the terminal emit with a shared flag — only the first non-cancellation
444        // terminal sends `StreamTerminated`, avoiding a double-emit. Relaxed ordering suffices: the
445        // flag has no associated payload to synchronise; the channel send is the synchronisation point.
446        let terminated = Arc::new(AtomicBool::new(false));
447
448        // Scoped to this stream rather than to the client: a fresh `account_stream` opens a fresh
449        // subscription, whose snapshot the new consumer has not seen. It must outlive a reconnect,
450        // which it does -- the SDK reconnects underneath this task, not around it.
451        let fills_dedup = new_dedup_cache();
452
453        // Spawn task to process fills
454        let fills_event_tx = event_tx.clone();
455        let fills_cancel = cancel_token.clone();
456        let fills_terminated = terminated.clone();
457        tokio::spawn(async move {
458            loop {
459                tokio::select! {
460                    biased;
461                    () = fills_cancel.cancelled() => {
462                        debug!("Fills task cancelled");
463                        return;
464                    }
465                    msg = fills_rx.recv() => {
466                        let Some(msg) = msg else {
467                            debug!("Fills receiver closed");
468                            // SDK gave up on the stream (channel closed). Emit a single terminal
469                            // StreamTerminated across both tasks (guarded by the shared flag).
470                            if fills_terminated
471                                .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
472                                .is_ok()
473                            {
474                                emit_stream_terminated(
475                                    &fills_event_tx,
476                                    ExchangeId::HyperliquidPerp,
477                                    StreamTerminationReason::Error(
478                                        "hyperliquid account stream closed".to_string(),
479                                    ),
480                                );
481                            }
482                            return;
483                        };
484                        match msg {
485                            Message::UserFills(fills) => {
486                                // Hyperliquid opens a `userFills` subscription with a snapshot of
487                                // recent fills, and the SDK resubscribes on every reconnect. Each
488                                // reconnect therefore redelivers fills already sent. A trade is a
489                                // delta the consumer accumulates, so redelivering one double-counts
490                                // filled quantity and fees -- hence the dedup cache.
491                                //
492                                // Order updates on the sibling task are deliberately NOT deduped:
493                                // they are absolute state, so a replayed one is idempotent, while
494                                // dropping one could strand the consumer on stale state.
495                                for fill in fills.data.fills {
496                                    let Some(event) = fill_to_account_event(&fill) else {
497                                        continue;
498                                    };
499                                    if let Some(key) = dedup_key_from_event(&event)
500                                        && is_duplicate(&fills_dedup, key)
501                                    {
502                                        trace!(
503                                            tid = fill.tid,
504                                            "Hyperliquid dedup: skipping fill already delivered"
505                                        );
506                                        continue;
507                                    }
508                                    if fills_event_tx.send(event).is_err() {
509                                        debug!("Fills event channel closed");
510                                        return;
511                                    }
512                                }
513                            }
514                            Message::NoData => {
515                                warn!("UserFills WebSocket disconnected");
516                            }
517                            Message::HyperliquidError(e) => {
518                                // Transient, non-terminal: the loop continues. Log only — no
519                                // in-band event (consumers took no action on the old StreamError).
520                                error!(%e, "UserFills WebSocket error");
521                            }
522                            _ => {}
523                        }
524                    }
525                }
526            }
527        });
528
529        // Spawn task to process order updates
530        // NOTE: ws_client is moved here to keep the WebSocket alive. When this task
531        // exits (via cancellation or channel close), the WebSocket connection closes,
532        // which causes fills_rx to also close.
533        let orders_event_tx = event_tx;
534        let orders_cancel = cancel_token.clone();
535        let orders_terminated = terminated;
536        tokio::spawn(async move {
537            let _ws_client = ws_client;
538
539            loop {
540                tokio::select! {
541                    biased;
542                    () = orders_cancel.cancelled() => {
543                        debug!("Orders task cancelled");
544                        return;
545                    }
546                    msg = orders_rx.recv() => {
547                        let Some(msg) = msg else {
548                            debug!("Orders receiver closed");
549                            // SDK gave up on the stream (channel closed). Emit a single terminal
550                            // StreamTerminated across both tasks (guarded by the shared flag).
551                            if orders_terminated
552                                .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
553                                .is_ok()
554                            {
555                                emit_stream_terminated(
556                                    &orders_event_tx,
557                                    ExchangeId::HyperliquidPerp,
558                                    StreamTerminationReason::Error(
559                                        "hyperliquid account stream closed".to_string(),
560                                    ),
561                                );
562                            }
563                            return;
564                        };
565                        match msg {
566                            Message::OrderUpdates(updates) => {
567                                for update in updates.data {
568                                    if let Some(event) = order_update_to_account_event(&update)
569                                        && orders_event_tx.send(event).is_err()
570                                    {
571                                        debug!("Orders event channel closed");
572                                        return;
573                                    }
574                                }
575                            }
576                            Message::NoData => {
577                                warn!("OrderUpdates WebSocket disconnected");
578                            }
579                            Message::HyperliquidError(e) => {
580                                // Transient, non-terminal: the loop continues. Log only — no
581                                // in-band event (consumers took no action on the old StreamError).
582                                error!(%e, "OrderUpdates WebSocket error");
583                            }
584                            _ => {}
585                        }
586                    }
587                }
588            }
589        });
590
591        // Wrap stream with drop guard that cancels tasks
592        let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
593        let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
594        Ok(guarded_stream.boxed())
595    }
596
597    async fn cancel_order(
598        &self,
599        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
600    ) -> Option<UnindexedOrderResponseCancel> {
601        use crate::order::{request::OrderResponseCancel, state::Cancelled};
602        use hyperliquid_rust_sdk::{
603            ClientCancelRequest, ClientCancelRequestCloid, ExchangeResponseStatus,
604        };
605        use uuid::Uuid;
606
607        let coin = instrument_to_perp_coin(request.key.instrument);
608
609        enum CancelMethod {
610            ByOid(u64),
611            ByCloid(Uuid),
612        }
613
614        // How the venue addresses the order decides which endpoint cancels it. This reads the
615        // identifier's kind rather than inferring it from the string's shape: an oid and a cloid
616        // are different identifiers that happen to both be text, and telling them apart by parsing
617        // means a cloid which parses as a number gets cancelled as somebody else's oid.
618        let cancel_method = match &request.state.id {
619            Some(VenueOrderId::Assigned(id)) => {
620                id.0.parse::<u64>()
621                    .map(CancelMethod::ByOid)
622                    .map_err(|_| "venue order id is not a numeric Hyperliquid oid")
623            }
624            // The venue accepted the order without assigning an oid, so the only handle on it is
625            // the cloid it was placed with -- which placement derives from the client id the same
626            // way, and refuses the order if it cannot.
627            Some(VenueOrderId::ClientAssigned) => cid_to_cloid(&request.key.cid)
628                .map(CancelMethod::ByCloid)
629                .ok_or("order is addressable only by its client id, which is not a canonical UUID"),
630            None => Err("order is still in flight; the venue has acknowledged nothing to cancel"),
631        };
632
633        let cancel_method = match cancel_method {
634            Ok(method) => method,
635            Err(reason) => {
636                warn!(%reason, cid = %request.key.cid, "Cannot determine how to cancel order");
637                return Some(OrderResponseCancel {
638                    key: OrderKey {
639                        exchange: ExchangeId::HyperliquidPerp,
640                        instrument: request.key.instrument.clone(),
641                        strategy: request.key.strategy.clone(),
642                        cid: request.key.cid.clone(),
643                    },
644                    state: Err(UnindexedOrderError::Rejected(
645                        crate::error::ApiError::OrderRejected(reason.to_string()),
646                    )),
647                });
648            }
649        };
650
651        // Terminal states keep a plain `OrderId`: nothing compares them for identity, so an order
652        // the venue never named records the client id it was addressed by instead.
653        let cancelled_id = match &request.state.id {
654            Some(venue_id) => venue_id.or_client_id(&request.key.cid),
655            None => OrderId(request.key.cid.0.clone()),
656        };
657
658        let response = match cancel_method {
659            CancelMethod::ByOid(oid) => {
660                debug!(oid, "Cancelling by OID");
661                let cancel_request = ClientCancelRequest { asset: coin, oid };
662                self.exchange_client.cancel(cancel_request, None).await
663            }
664            CancelMethod::ByCloid(cloid) => {
665                debug!(%cloid, "Cancelling by cloid (no oid assigned)");
666                let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
667                self.exchange_client
668                    .cancel_by_cloid(cancel_request, None)
669                    .await
670            }
671        };
672
673        let response = match response {
674            Ok(r) => r,
675            Err(e) => {
676                warn!(%e, "Cancel order failed (transport)");
677                return Some(OrderResponseCancel {
678                    key: OrderKey {
679                        exchange: ExchangeId::HyperliquidPerp,
680                        instrument: request.key.instrument.clone(),
681                        strategy: request.key.strategy.clone(),
682                        cid: request.key.cid.clone(),
683                    },
684                    state: Err(map_order_error(e, request.key.instrument)),
685                });
686            }
687        };
688
689        match response {
690            ExchangeResponseStatus::Ok(_) => {
691                debug!("Cancel order accepted");
692                // Hyperliquid cancel response doesn't include an exchange timestamp
693                Some(OrderResponseCancel {
694                    key: OrderKey {
695                        exchange: ExchangeId::HyperliquidPerp,
696                        instrument: request.key.instrument.clone(),
697                        strategy: request.key.strategy.clone(),
698                        cid: request.key.cid.clone(),
699                    },
700                    state: Ok(Cancelled::new(
701                        cancelled_id.clone(),
702                        Utc::now(),
703                        Decimal::ZERO, // Cancel response doesn't include filled quantity
704                    )),
705                })
706            }
707            ExchangeResponseStatus::Err(msg) => {
708                warn!(%msg, "Cancel rejected by exchange");
709                Some(OrderResponseCancel {
710                    key: OrderKey {
711                        exchange: ExchangeId::HyperliquidPerp,
712                        instrument: request.key.instrument.clone(),
713                        strategy: request.key.strategy.clone(),
714                        cid: request.key.cid.clone(),
715                    },
716                    state: Err(UnindexedOrderError::Rejected(
717                        crate::error::ApiError::OrderRejected(msg),
718                    )),
719                })
720            }
721        }
722    }
723
724    async fn open_order(
725        &self,
726        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
727    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
728        use hyperliquid_rust_sdk::{
729            ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, ExchangeDataStatus,
730            ExchangeResponseStatus,
731        };
732
733        let make_rejected =
734            |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
735                Order {
736                    key: OrderKey {
737                        exchange: ExchangeId::HyperliquidPerp,
738                        instrument: request.key.instrument.clone(),
739                        strategy: request.key.strategy.clone(),
740                        cid: request.key.cid.clone(),
741                    },
742                    side: request.state.side,
743                    price: request.state.price,
744                    quantity: request.state.quantity,
745                    kind: request.state.kind,
746                    time_in_force: request.state.time_in_force,
747                    state: OrderState::inactive(OrderError::Rejected(
748                        crate::error::ApiError::OrderRejected(msg),
749                    )),
750                }
751            };
752
753        let make_unsupported =
754            |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
755                Order {
756                    key: OrderKey {
757                        exchange: ExchangeId::HyperliquidPerp,
758                        instrument: request.key.instrument.clone(),
759                        strategy: request.key.strategy.clone(),
760                        cid: request.key.cid.clone(),
761                    },
762                    side: request.state.side,
763                    price: request.state.price,
764                    quantity: request.state.quantity,
765                    kind: request.state.kind,
766                    time_in_force: request.state.time_in_force,
767                    state: OrderState::inactive(OrderError::UnsupportedOrderType(msg)),
768                }
769            };
770
771        let coin = instrument_to_perp_coin(request.key.instrument);
772        let is_buy = request.state.side == Side::Buy;
773
774        match request.state.kind {
775            OrderKind::Market => {
776                return Some(make_unsupported(
777                    "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
778                        .to_string(),
779                ));
780            }
781            OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. } => {
782                return Some(make_unsupported(
783                    "Hyperliquid does not support trailing stop orders".to_string(),
784                ));
785            }
786            OrderKind::Limit
787            | OrderKind::Stop { .. }
788            | OrderKind::StopLimit { .. }
789            | OrderKind::TakeProfit { .. }
790            | OrderKind::TakeProfitLimit { .. } => {}
791        }
792
793        // The venue reports every order under its cloid, so an order placed without one could
794        // not be matched to the id the caller tracks it by. See the module docs.
795        let Some(cloid) = cid_to_cloid(&request.key.cid) else {
796            return Some(make_rejected(CLOID_REQUIRED.to_string()));
797        };
798
799        let limit_px = match request.state.kind {
800            // Market triggers: SDK uses trigger_px as limit_px
801            OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
802                round_to_5_sig_figs(trigger_price)
803            }
804            _ => match request.state.price {
805                Some(p) => round_to_5_sig_figs(p),
806                None => {
807                    return Some(make_rejected(
808                        "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
809                            .to_string(),
810                    ));
811                }
812            },
813        };
814
815        let sz = round_to_5_sig_figs(request.state.quantity);
816
817        // Map time-in-force (warn if FOK is substituted with IOC)
818        if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
819            warn!(
820                instrument = %request.key.instrument,
821                "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
822            );
823        }
824        let tif = map_tif(&request.state.time_in_force).to_string();
825
826        // Build order_type based on OrderKind
827        let order_type = match request.state.kind {
828            OrderKind::Limit => ClientOrder::Limit(ClientLimit { tif }),
829            OrderKind::Stop { trigger_price } => ClientOrder::Trigger(ClientTrigger {
830                is_market: true,
831                trigger_px: round_to_5_sig_figs(trigger_price),
832                tpsl: "sl".to_string(),
833            }),
834            OrderKind::StopLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
835                is_market: false,
836                trigger_px: round_to_5_sig_figs(trigger_price),
837                tpsl: "sl".to_string(),
838            }),
839            OrderKind::TakeProfit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
840                is_market: true,
841                trigger_px: round_to_5_sig_figs(trigger_price),
842                tpsl: "tp".to_string(),
843            }),
844            OrderKind::TakeProfitLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
845                is_market: false,
846                trigger_px: round_to_5_sig_figs(trigger_price),
847                tpsl: "tp".to_string(),
848            }),
849            // Already rejected above
850            OrderKind::Market
851            | OrderKind::TrailingStop { .. }
852            | OrderKind::TrailingStopLimit { .. } => {
853                unreachable!("unsupported order kinds rejected earlier")
854            }
855        };
856
857        let order_request = ClientOrderRequest {
858            asset: coin,
859            is_buy,
860            reduce_only: request.state.reduce_only,
861            limit_px,
862            sz,
863            cloid: Some(cloid),
864            order_type,
865        };
866
867        let response = match self.exchange_client.order(order_request, None).await {
868            Ok(r) => r,
869            Err(e) => {
870                warn!(%e, "Open order failed");
871                return Some(Order {
872                    key: OrderKey {
873                        exchange: ExchangeId::HyperliquidPerp,
874                        instrument: request.key.instrument.clone(),
875                        strategy: request.key.strategy.clone(),
876                        cid: request.key.cid.clone(),
877                    },
878                    side: request.state.side,
879                    price: request.state.price,
880                    quantity: request.state.quantity,
881                    kind: request.state.kind,
882                    time_in_force: request.state.time_in_force,
883                    state: OrderState::inactive(map_order_error(e, request.key.instrument)),
884                });
885            }
886        };
887
888        // Parse response
889        let state = match response {
890            ExchangeResponseStatus::Ok(exchange_resp) => {
891                // Check status from response data
892                let status = exchange_resp
893                    .data
894                    .and_then(|d| d.statuses.into_iter().next());
895
896                match status {
897                    Some(ExchangeDataStatus::Resting(resting)) => {
898                        debug!(oid = resting.oid, "Order resting");
899                        OrderState::active(Open {
900                            id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
901                            time_exchange: Utc::now(),
902                            filled_quantity: Decimal::ZERO,
903                        })
904                    }
905                    Some(ExchangeDataStatus::Filled(filled)) => {
906                        debug!(oid = filled.oid, avg_px = %filled.avg_px, "Order filled");
907                        // Hyperliquid provides avg_px for filled orders
908                        let avg_price = parse_decimal(&filled.avg_px, "avg_px");
909                        OrderState::fully_filled(Filled::new(
910                            OrderId(format_smolstr!("{}", filled.oid)),
911                            Utc::now(),
912                            parse_decimal(&filled.total_sz, "total_sz")
913                                .unwrap_or(request.state.quantity),
914                            avg_price,
915                        ))
916                    }
917                    Some(ExchangeDataStatus::Error(msg)) => {
918                        warn!(%msg, "Order rejected by exchange");
919                        OrderState::inactive(OrderError::Rejected(
920                            crate::error::ApiError::OrderRejected(msg),
921                        ))
922                    }
923                    Some(
924                        ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
925                    ) => {
926                        // The venue answered without an oid, so the order's only handle is the
927                        // cloid it was placed with. A standalone trigger order is answered
928                        // `resting` with an oid; these statuses belong to grouped orders.
929                        debug!(cid = %request.key.cid, "Order waiting under its cloid");
930                        OrderState::active(Open {
931                            id: VenueOrderId::ClientAssigned,
932                            time_exchange: Utc::now(),
933                            filled_quantity: Decimal::ZERO,
934                        })
935                    }
936                    Some(ExchangeDataStatus::Success) | None => {
937                        // Generic success without order ID — SDK didn't return structured data.
938                        // This shouldn't happen for limit orders; reject to avoid silent failures.
939                        warn!("Order accepted but no order ID returned");
940                        OrderState::inactive(OrderError::Rejected(
941                            crate::error::ApiError::OrderRejected(
942                                "no order ID in response".to_string(),
943                            ),
944                        ))
945                    }
946                }
947            }
948            ExchangeResponseStatus::Err(msg) => {
949                warn!(%msg, "Order rejected");
950                OrderState::inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
951                    msg,
952                )))
953            }
954        };
955
956        Some(Order {
957            key: OrderKey {
958                exchange: ExchangeId::HyperliquidPerp,
959                instrument: request.key.instrument.clone(),
960                strategy: request.key.strategy.clone(),
961                cid: request.key.cid.clone(),
962            },
963            side: request.state.side,
964            price: request.state.price,
965            quantity: request.state.quantity,
966            kind: request.state.kind,
967            time_in_force: request.state.time_in_force,
968            state,
969        })
970    }
971
972    async fn fetch_balances(
973        &self,
974        _assets: &[AssetNameExchange],
975    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
976        let address = self.wallet_h160();
977
978        let user_state = self
979            .info_client
980            .user_state(address)
981            .await
982            .map_err(map_sdk_error)?;
983
984        let now = Utc::now();
985
986        // Hyperliquid perps use USDC as the only collateral
987        let account_value =
988            parse_decimal(&user_state.margin_summary.account_value, "account_value")
989                .unwrap_or(Decimal::ZERO);
990        let margin_used = parse_decimal(
991            &user_state.margin_summary.total_margin_used,
992            "total_margin_used",
993        )
994        .unwrap_or(Decimal::ZERO);
995
996        // Free balance can go negative during liquidation; clamp to zero
997        let free_balance = (account_value - margin_used).max(Decimal::ZERO);
998        Ok(vec![AssetBalance::new(
999            AssetNameExchange::from(USDC_ASSET),
1000            Balance::new(account_value, free_balance),
1001            now,
1002        )])
1003    }
1004
1005    async fn fetch_open_orders(
1006        &self,
1007        instruments: &[InstrumentNameExchange],
1008    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1009        let address = self.wallet_h160();
1010
1011        let open_orders = open_orders(&self.info_client, address).await?;
1012
1013        let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1014            None
1015        } else {
1016            let mut set = HashSet::with_capacity(instruments.len());
1017            set.extend(instruments.iter().cloned());
1018            Some(set)
1019        };
1020
1021        Ok(open_orders
1022            .iter()
1023            .filter_map(|order| {
1024                let instrument = perp_coin_to_instrument(&order.coin);
1025                if instrument_filter
1026                    .as_ref()
1027                    .is_some_and(|f| !f.contains(&instrument))
1028                {
1029                    return None;
1030                }
1031                open_order_to_order(order, ExchangeId::HyperliquidPerp, instrument)
1032            })
1033            .collect())
1034    }
1035
1036    async fn fetch_trades(
1037        &self,
1038        time_since: DateTime<Utc>,
1039        instruments: &[InstrumentNameExchange],
1040    ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1041        let address = self.wallet_h160();
1042
1043        let fills = user_fills(&self.info_client, address).await?;
1044
1045        // Clamp to 0 for dates before epoch (shouldn't happen in practice)
1046        #[allow(clippy::cast_sign_loss)] // timestamp_millis >= 0 after max(0)
1047        let time_since_ms = time_since.timestamp_millis().max(0) as u64;
1048
1049        let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1050            None
1051        } else {
1052            let mut set = HashSet::with_capacity(instruments.len());
1053            set.extend(instruments.iter().cloned());
1054            Some(set)
1055        };
1056
1057        let mut result = Vec::new();
1058        for fill in fills {
1059            // Filter by time
1060            if fill.time < time_since_ms {
1061                continue;
1062            }
1063
1064            let instrument = perp_coin_to_instrument(&fill.coin);
1065
1066            if instrument_filter
1067                .as_ref()
1068                .is_some_and(|f| !f.contains(&instrument))
1069            {
1070                continue;
1071            }
1072
1073            let Some(side) = parse_side(&fill.side) else {
1074                continue;
1075            };
1076            let Some(price) = parse_decimal(&fill.px, "px") else {
1077                continue;
1078            };
1079            let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1080                continue;
1081            };
1082            let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1083
1084            let Some(time_exchange) = millis_to_datetime(fill.time) else {
1085                warn!(time = fill.time, "Invalid fill timestamp, skipping");
1086                continue;
1087            };
1088
1089            result.push(Trade {
1090                id: TradeId(format_smolstr!("{}", fill.tid)),
1091                order_id: OrderId(format_smolstr!("{}", fill.oid)),
1092                instrument,
1093                strategy: StrategyId::unknown(),
1094                time_exchange,
1095                side,
1096                price,
1097                quantity,
1098                // `TradeInfo` carries no cumulative filled quantity; Hyperliquid reports order
1099                // state as its own `OrderUpdate` message.
1100                order_filled_quantity: None,
1101                fees: AssetFees {
1102                    asset: AssetNameExchange::from("USDC"),
1103                    fees: fee,
1104                    fees_quote: Some(fee),
1105                },
1106            });
1107        }
1108
1109        Ok(result)
1110    }
1111}
1112
1113/// Report one perpetual position from Hyperliquid's user state.
1114///
1115/// [`PositionReport::Open`] for a non-zero size, [`PositionReport::Flat`] for zero, and
1116/// [`PositionReport::Unreported`] when the size does not parse (with a warning): an unreadable
1117/// size establishes nothing, so it must not read as flat.
1118fn perp_position_report(
1119    position: &hyperliquid_rust_sdk::PositionData,
1120    now: DateTime<Utc>,
1121) -> PositionReport {
1122    let Some(quantity) = parse_decimal(&position.szi, "szi") else {
1123        return PositionReport::Unreported;
1124    };
1125    PositionReport::from_position(Position::new(
1126        quantity,
1127        position
1128            .entry_px
1129            .as_ref()
1130            .and_then(|p| parse_decimal(p, "entry_px")),
1131        parse_decimal(&position.unrealized_pnl, "unrealized_pnl"),
1132        parse_decimal(&position.margin_used, "margin_used"),
1133        position
1134            .liquidation_px
1135            .as_ref()
1136            .and_then(|p| parse_decimal(p, "liquidation_px")),
1137        Some(Decimal::from(position.leverage.value)),
1138        now,
1139    ))
1140}
1141
1142/// Convert SDK TradeInfo (fill) to AccountEvent::Trade.
1143fn fill_to_account_event(fill: &hyperliquid_rust_sdk::TradeInfo) -> Option<UnindexedAccountEvent> {
1144    let side = parse_side(&fill.side)?;
1145    let price = parse_decimal(&fill.px, "fill.px")?;
1146    let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1147    let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1148    let time_exchange = millis_to_datetime(fill.time)?;
1149    let instrument = perp_coin_to_instrument(&fill.coin);
1150    let order_id = OrderId(format_smolstr!("{}", fill.oid));
1151
1152    let trade = Trade {
1153        id: TradeId(format_smolstr!("{}", fill.tid)),
1154        order_id,
1155        instrument,
1156        strategy: StrategyId::unknown(),
1157        time_exchange,
1158        side,
1159        price,
1160        quantity,
1161        // `TradeInfo` carries no cumulative filled quantity, so the order's state must be learned
1162        // from an `OrderUpdate` -- which Hyperliquid sends as its own message.
1163        order_filled_quantity: None,
1164        fees: AssetFees {
1165            asset: AssetNameExchange::from("USDC"),
1166            fees: fee,
1167            fees_quote: Some(fee),
1168        },
1169    };
1170
1171    Some(AccountEvent::new(
1172        ExchangeId::HyperliquidPerp,
1173        AccountEventKind::Trade(trade),
1174    ))
1175}
1176
1177/// Convert SDK OrderUpdate to AccountEvent::OrderSnapshot.
1178fn order_update_to_account_event(
1179    update: &hyperliquid_rust_sdk::OrderUpdate,
1180) -> Option<UnindexedAccountEvent> {
1181    common::order_update_to_account_event(
1182        update,
1183        ExchangeId::HyperliquidPerp,
1184        perp_coin_to_instrument(&update.order.coin),
1185    )
1186}
1187
1188#[cfg(test)]
1189// Test code: panics on bad input are acceptable
1190#[allow(clippy::unwrap_used)]
1191mod tests {
1192    use super::*;
1193    use rust_decimal_macros::dec;
1194    use rustrade_integration::collection::snapshot::Snapshot;
1195
1196    fn perp_position(szi: &str) -> hyperliquid_rust_sdk::PositionData {
1197        serde_json::from_value(serde_json::json!({
1198            "coin": "BTC",
1199            "entryPx": "50000.0",
1200            "leverage": {"type": "cross", "value": 5},
1201            "liquidationPx": null,
1202            "marginUsed": "100.0",
1203            "positionValue": "500.0",
1204            "returnOnEquity": "0.0",
1205            "szi": szi,
1206            "unrealizedPnl": "1.5",
1207            "maxLeverage": 50,
1208            "cumFunding": {"allTime": "0", "sinceOpen": "0", "sinceChange": "0"}
1209        }))
1210        .unwrap()
1211    }
1212
1213    #[test]
1214    fn perp_position_report_is_open_flat_or_unreported() {
1215        let now = Utc::now();
1216        let open = perp_position_report(&perp_position("-0.01"), now);
1217        let position = open.open().unwrap();
1218        assert_eq!(
1219            (position.quantity, position.entry_price, position.leverage),
1220            (dec!(-0.01), Some(dec!(50000.0)), Some(dec!(5)))
1221        );
1222        assert_eq!(
1223            perp_position_report(&perp_position("0.0"), now),
1224            PositionReport::Flat
1225        );
1226        assert_eq!(
1227            perp_position_report(&perp_position("not a number"), now),
1228            PositionReport::Unreported
1229        );
1230    }
1231
1232    #[test]
1233    fn test_fill_to_account_event() {
1234        let fill_json = r#"{
1235            "coin": "BTC",
1236            "side": "B",
1237            "px": "65000.5",
1238            "sz": "0.1",
1239            "time": 1714100000000,
1240            "hash": "0xabc123",
1241            "startPosition": "0",
1242            "dir": "Open Long",
1243            "closedPnl": "0",
1244            "oid": 12345,
1245            "cloid": null,
1246            "crossed": false,
1247            "fee": "0.65",
1248            "feeToken": "USDC",
1249            "tid": 99999
1250        }"#;
1251
1252        let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1253        let event = fill_to_account_event(&fill).unwrap();
1254
1255        assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
1256        match event.kind {
1257            AccountEventKind::Trade(trade) => {
1258                assert_eq!(trade.instrument.as_ref(), "BTC-USD-PERP");
1259                assert_eq!(
1260                    trade.id.0, "99999",
1261                    "the id is the fill's tid, not its hash"
1262                );
1263                assert_eq!(trade.side, Side::Buy);
1264                assert_eq!(trade.price, dec!(65000.5));
1265                assert_eq!(trade.quantity, dec!(0.1));
1266                assert_eq!(trade.fees.fees, dec!(0.65));
1267            }
1268            _ => panic!("Expected Trade event"),
1269        }
1270    }
1271
1272    #[test]
1273    fn test_fill_to_account_event_sell() {
1274        let fill_json = r#"{
1275            "coin": "ETH",
1276            "side": "A",
1277            "px": "3200",
1278            "sz": "1.5",
1279            "time": 1714100000000,
1280            "hash": "0xdef456",
1281            "startPosition": "1.5",
1282            "dir": "Close Long",
1283            "closedPnl": "150.0",
1284            "oid": 12346,
1285            "cloid": null,
1286            "crossed": true,
1287            "fee": "4.8",
1288            "feeToken": "USDC",
1289            "tid": 100000
1290        }"#;
1291
1292        let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1293        let event = fill_to_account_event(&fill).unwrap();
1294
1295        match event.kind {
1296            AccountEventKind::Trade(trade) => {
1297                assert_eq!(trade.instrument.as_ref(), "ETH-USD-PERP");
1298                assert_eq!(trade.side, Side::Sell);
1299                assert_eq!(trade.price, dec!(3200));
1300                assert_eq!(trade.quantity, dec!(1.5));
1301            }
1302            _ => panic!("Expected Trade event"),
1303        }
1304    }
1305
1306    /// Build a perp `TradeInfo` sharing one transaction hash with its siblings.
1307    fn sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
1308        let json = format!(
1309            r#"{{
1310                "coin": "BTC", "side": "B", "px": "{px}", "sz": "0.1",
1311                "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
1312                "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
1313                "crossed": true, "fee": "0.65", "feeToken": "USDC", "tid": {tid}
1314            }}"#
1315        );
1316        serde_json::from_str(&json).unwrap()
1317    }
1318
1319    #[test]
1320    fn a_trade_id_identifies_the_fill_not_the_transaction() {
1321        // One aggressive order sweeping two resting levels: same `hash`, same `oid`, two fills.
1322        // Deriving the id from `hash` gave both the same `TradeId`, so anything reconciling on it
1323        // treated them as one fill and dropped the second -- understating filled quantity and fees.
1324        let first = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1325        let second = fill_to_account_event(&sweep_fill(222, "65001.0")).unwrap();
1326
1327        let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
1328            (first.kind, second.kind)
1329        else {
1330            panic!("Expected Trade events");
1331        };
1332
1333        assert_eq!(first.order_id, second.order_id, "one order produced both");
1334        assert_ne!(first.id, second.id, "but they are distinct fills");
1335        assert_eq!(first.id.0, "111");
1336        assert_eq!(second.id.0, "222");
1337    }
1338
1339    #[test]
1340    fn a_replayed_fill_is_delivered_once() {
1341        // The `userFills` subscription opens with a snapshot of recent fills, and the SDK
1342        // resubscribes on reconnect -- so the same fill arrives again on every reconnect.
1343        let cache = new_dedup_cache();
1344        let event = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1345        let replay = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1346
1347        assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
1348        assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
1349    }
1350
1351    #[test]
1352    fn both_fills_of_one_sweep_survive_dedup() {
1353        // The guard against the fix and the dedup fighting each other: two fills of one sweep are
1354        // not duplicates of one another, and dedup must not collapse what the id now separates.
1355        let cache = new_dedup_cache();
1356        let first = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1357        let second = fill_to_account_event(&sweep_fill(222, "65001.0")).unwrap();
1358
1359        assert!(!is_duplicate(&cache, dedup_key_from_event(&first).unwrap()));
1360        assert!(!is_duplicate(
1361            &cache,
1362            dedup_key_from_event(&second).unwrap()
1363        ));
1364    }
1365
1366    #[test]
1367    fn one_tid_on_two_instruments_is_two_fills() {
1368        // Hyperliquid documents `tid` as qualified by coin rather than globally unique, which is
1369        // why the dedup key carries the instrument.
1370        let cache = new_dedup_cache();
1371        let btc = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1372
1373        let eth_json = r#"{
1374            "coin": "ETH", "side": "B", "px": "3200", "sz": "1.5",
1375            "time": 1714100000000, "hash": "0xother", "startPosition": "0",
1376            "dir": "Open Long", "closedPnl": "0", "oid": 9, "cloid": null,
1377            "crossed": true, "fee": "4.8", "feeToken": "USDC", "tid": 111
1378        }"#;
1379        let eth: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(eth_json).unwrap();
1380        let eth = fill_to_account_event(&eth).unwrap();
1381
1382        assert!(!is_duplicate(&cache, dedup_key_from_event(&btc).unwrap()));
1383        assert!(!is_duplicate(&cache, dedup_key_from_event(&eth).unwrap()));
1384    }
1385
1386    #[test]
1387    fn test_order_update_to_account_event_open() {
1388        let update_json = r#"{
1389            "order": {
1390                "coin": "BTC",
1391                "side": "B",
1392                "limitPx": "64000",
1393                "sz": "0.5",
1394                "oid": 12345,
1395                "timestamp": 1714100000000,
1396                "origSz": "0.5",
1397                "cloid": null
1398            },
1399            "status": "open",
1400            "statusTimestamp": 1714100000000
1401        }"#;
1402
1403        let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1404        let event = order_update_to_account_event(&update).unwrap();
1405
1406        assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
1407        match event.kind {
1408            AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1409                assert_eq!(order.key.instrument.as_ref(), "BTC-USD-PERP");
1410                assert_eq!(order.side, Side::Buy);
1411                assert_eq!(order.price, Some(dec!(64000)));
1412                assert_eq!(order.quantity, dec!(0.5));
1413                assert!(matches!(
1414                    order.state,
1415                    crate::order::state::OrderState::Active(_)
1416                ));
1417            }
1418            _ => panic!("Expected OrderSnapshot event"),
1419        }
1420    }
1421
1422    #[test]
1423    fn test_order_update_to_account_event_filled() {
1424        let update_json = r#"{
1425            "order": {
1426                "coin": "ETH",
1427                "side": "A",
1428                "limitPx": "3250",
1429                "sz": "0",
1430                "oid": 12346,
1431                "timestamp": 1714100000000,
1432                "origSz": "2.0",
1433                "cloid": null
1434            },
1435            "status": "filled",
1436            "statusTimestamp": 1714100001000
1437        }"#;
1438
1439        let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1440        let event = order_update_to_account_event(&update).unwrap();
1441
1442        match event.kind {
1443            AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1444                assert_eq!(order.side, Side::Sell);
1445                assert!(matches!(
1446                    order.state,
1447                    crate::order::state::OrderState::Inactive(
1448                        crate::order::state::InactiveOrderState::FullyFilled(_)
1449                    )
1450                ));
1451            }
1452            _ => panic!("Expected OrderSnapshot event"),
1453        }
1454    }
1455
1456    #[test]
1457    fn test_order_update_to_account_event_cancelled() {
1458        let update_json = r#"{
1459            "order": {
1460                "coin": "SOL",
1461                "side": "B",
1462                "limitPx": "150",
1463                "sz": "10",
1464                "oid": 12347,
1465                "timestamp": 1714100000000,
1466                "origSz": "10",
1467                "cloid": null
1468            },
1469            "status": "canceled",
1470            "statusTimestamp": 1714100002000
1471        }"#;
1472
1473        let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1474        let event = order_update_to_account_event(&update).unwrap();
1475
1476        match event.kind {
1477            AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1478                assert_eq!(order.key.instrument.as_ref(), "SOL-USD-PERP");
1479                assert!(matches!(
1480                    order.state,
1481                    crate::order::state::OrderState::Inactive(
1482                        crate::order::state::InactiveOrderState::Cancelled(_)
1483                    )
1484                ));
1485            }
1486            _ => panic!("Expected OrderSnapshot event"),
1487        }
1488    }
1489
1490    #[test]
1491    fn test_order_update_unknown_status_returns_none() {
1492        let update_json = r#"{
1493            "order": {
1494                "coin": "BTC",
1495                "side": "B",
1496                "limitPx": "64000",
1497                "sz": "0.5",
1498                "oid": 12345,
1499                "timestamp": 1714100000000,
1500                "origSz": "0.5",
1501                "cloid": null
1502            },
1503            "status": "unknown_status",
1504            "statusTimestamp": 1714100000000
1505        }"#;
1506
1507        let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1508        let event = order_update_to_account_event(&update);
1509        assert!(event.is_none());
1510    }
1511}