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