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