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