Skip to main content

nautilus_hyperliquid/websocket/
messages.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::borrow::Cow;
17
18use ahash::AHashMap;
19use derive_builder::Builder;
20#[cfg(test)]
21use nautilus_core::string::secret::REDACTED;
22use nautilus_core::{
23    serialization::{
24        decimal, deserialize_decimal, deserialize_decimal_from_str,
25        deserialize_optional_decimal_from_str, serialize_decimal_as_str,
26    },
27    string::secret::SecretString,
28};
29use nautilus_model::{
30    data::{
31        Bar, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate, OrderBookDeltas,
32        OrderBookDepth, QuoteTick, TradeTick,
33    },
34    identifiers::InstrumentId,
35    reports::{FillReport, OrderStatusReport},
36};
37use rust_decimal::Decimal;
38use serde::{Deserialize, Deserializer, Serialize, de::Error};
39use serde_json::value::RawValue;
40use ustr::Ustr;
41
42use crate::{
43    common::{
44        enums::{
45            HyperliquidBarInterval, HyperliquidFillDirection, HyperliquidLiquidationMethod,
46            HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide,
47            HyperliquidTimeInForce, HyperliquidTpSl, HyperliquidTwapStatus,
48        },
49        parse::is_false_or_none,
50    },
51    http::models::{HyperliquidExchangeAction, HyperliquidExchangeRequest, LeverageInfo},
52};
53
54/// Represents an outbound WebSocket message from client to Hyperliquid.
55#[derive(Debug, Clone, Serialize)]
56#[serde(tag = "method")]
57#[serde(rename_all = "lowercase")]
58pub enum HyperliquidWsRequest {
59    /// Subscribe to a data feed.
60    Subscribe {
61        /// Subscription details.
62        subscription: SubscriptionRequest,
63    },
64    /// Unsubscribe from a data feed.
65    Unsubscribe {
66        /// Subscription details to remove.
67        subscription: SubscriptionRequest,
68    },
69    /// Post a request (info or action).
70    Post {
71        /// Request ID for tracking.
72        id: u64,
73        /// Request payload.
74        request: PostRequest,
75    },
76    /// Ping for keepalive.
77    Ping,
78}
79
80/// Represents subscription request types for WebSocket feeds.
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82#[serde(tag = "type")]
83#[serde(rename_all = "camelCase")]
84pub enum SubscriptionRequest {
85    /// All mid prices across markets.
86    AllMids {
87        #[serde(skip_serializing_if = "Option::is_none")]
88        dex: Option<String>,
89    },
90    /// Aggregate asset contexts across all perp dexes.
91    AllDexsAssetCtxs,
92    /// Notifications for a user.
93    Notification { user: String },
94    /// Web data for frontend.
95    WebData2 { user: String },
96    /// Candlestick data.
97    Candle {
98        coin: Ustr,
99        interval: HyperliquidBarInterval,
100    },
101    /// Level 2 order book.
102    L2Book {
103        coin: Ustr,
104        #[serde(skip_serializing_if = "Option::is_none")]
105        #[serde(rename = "nSigFigs")]
106        n_sig_figs: Option<u32>,
107        #[serde(skip_serializing_if = "Option::is_none")]
108        mantissa: Option<u32>,
109    },
110    /// Trade updates.
111    Trades { coin: Ustr },
112    /// Order updates for a user.
113    OrderUpdates { user: String },
114    /// User events (fills, funding, liquidations).
115    UserEvents { user: String },
116    /// User fill history.
117    UserFills {
118        user: String,
119        #[serde(skip_serializing_if = "Option::is_none")]
120        #[serde(rename = "aggregateByTime")]
121        aggregate_by_time: Option<bool>,
122    },
123    /// User funding payments.
124    UserFundings { user: String },
125    /// User ledger updates (non-funding).
126    UserNonFundingLedgerUpdates { user: String },
127    /// Active asset context (for perpetuals).
128    ActiveAssetCtx { coin: Ustr },
129    /// Active spot asset context.
130    ActiveSpotAssetCtx { coin: Ustr },
131    /// Active asset data for user.
132    ActiveAssetData { user: String, coin: String },
133    /// TWAP slice fills.
134    UserTwapSliceFills { user: String },
135    /// TWAP history.
136    UserTwapHistory { user: String },
137    /// Best bid/offer updates.
138    Bbo { coin: Ustr },
139}
140
141/// Post request wrapper for info and action requests.
142#[derive(Debug, Clone, Serialize)]
143#[serde(tag = "type")]
144#[serde(rename_all = "lowercase")]
145pub enum PostRequest {
146    /// Info request (no signature required).
147    Info { payload: serde_json::Value },
148    /// Action request (requires signature).
149    Action {
150        payload: HyperliquidExchangeRequest<HyperliquidExchangeAction>,
151    },
152}
153
154/// Action payload with signature.
155#[derive(Debug, Clone, Serialize)]
156pub struct ActionPayload {
157    pub action: ActionRequest,
158    pub nonce: u64,
159    pub signature: SignatureData,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    #[serde(rename = "vaultAddress")]
162    pub vault_address: Option<String>,
163}
164
165/// Signature data.
166#[derive(Debug, Clone, Serialize)]
167pub struct SignatureData {
168    pub r: SecretString,
169    pub s: SecretString,
170    pub v: SecretString,
171}
172
173/// Action request types.
174///
175/// Fast-cancel flags are serialized only when true.
176#[derive(Debug, Clone, Serialize)]
177#[serde(tag = "type")]
178#[serde(rename_all = "lowercase")]
179pub enum ActionRequest {
180    /// Place orders.
181    Order {
182        orders: Vec<OrderRequest>,
183        grouping: String,
184    },
185    /// Cancel orders.
186    Cancel {
187        cancels: Vec<CancelRequest>,
188        #[serde(rename = "f", skip_serializing_if = "is_false_or_none")]
189        fast: Option<bool>,
190    },
191    /// Cancel orders by client order ID.
192    CancelByCloid {
193        cancels: Vec<CancelByCloidRequest>,
194        #[serde(rename = "f", skip_serializing_if = "is_false_or_none")]
195        fast: Option<bool>,
196    },
197    /// Modify orders.
198    Modify { modifies: Vec<ModifyRequest> },
199}
200
201impl ActionRequest {
202    /// Create a simple order action with default "na" grouping
203    ///
204    /// # Example
205    /// ```ignore
206    /// let action = ActionRequest::order(vec![order1, order2], "na");
207    /// ```
208    pub fn order(orders: Vec<OrderRequest>, grouping: impl Into<String>) -> Self {
209        Self::Order {
210            orders,
211            grouping: grouping.into(),
212        }
213    }
214
215    /// Create a cancel action for multiple orders
216    ///
217    /// # Example
218    /// ```ignore
219    /// let action = ActionRequest::cancel(vec![
220    ///     CancelRequest { a: 0, o: 12345 },
221    ///     CancelRequest { a: 1, o: 67890 },
222    /// ]);
223    /// ```
224    pub fn cancel(cancels: Vec<CancelRequest>) -> Self {
225        Self::Cancel {
226            cancels,
227            fast: None,
228        }
229    }
230
231    /// Create a cancel-by-cloid action
232    ///
233    /// # Example
234    /// ```ignore
235    /// let action = ActionRequest::cancel_by_cloid(vec![
236    ///     CancelByCloidRequest { asset: 0, cloid: "order-1".to_string() },
237    /// ]);
238    /// ```
239    pub fn cancel_by_cloid(cancels: Vec<CancelByCloidRequest>) -> Self {
240        Self::CancelByCloid {
241            cancels,
242            fast: None,
243        }
244    }
245
246    /// Create a modify action for multiple orders
247    ///
248    /// # Example
249    /// ```ignore
250    /// let action = ActionRequest::modify(vec![
251    ///     ModifyRequest { oid: 12345, order: new_order },
252    /// ]);
253    /// ```
254    pub fn modify(modifies: Vec<ModifyRequest>) -> Self {
255        Self::Modify { modifies }
256    }
257}
258
259/// Order placement request.
260#[derive(Debug, Clone, Serialize, Builder)]
261pub struct OrderRequest {
262    /// Asset ID.
263    pub a: u32,
264    /// Buy side (true = buy, false = sell).
265    pub b: bool,
266    /// Price.
267    pub p: String,
268    /// Size.
269    pub s: String,
270    /// Reduce only.
271    pub r: bool,
272    /// Order type.
273    pub t: OrderTypeRequest,
274    /// Client order ID (optional).
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub c: Option<String>,
277}
278
279/// Order type in request format.
280#[derive(Debug, Clone, Serialize)]
281#[serde(tag = "type")]
282#[serde(rename_all = "lowercase")]
283pub enum OrderTypeRequest {
284    Limit {
285        tif: TimeInForceRequest,
286    },
287    Trigger {
288        #[serde(rename = "isMarket")]
289        is_market: bool,
290        #[serde(rename = "triggerPx")]
291        trigger_px: String,
292        tpsl: TpSlRequest,
293    },
294}
295
296/// Time in force in request format.
297#[derive(Debug, Clone, Serialize)]
298#[serde(rename_all = "PascalCase")]
299pub enum TimeInForceRequest {
300    Alo,
301    Ioc,
302    Gtc,
303}
304
305/// TP/SL in request format.
306#[derive(Debug, Clone, Serialize)]
307#[serde(rename_all = "lowercase")]
308pub enum TpSlRequest {
309    Tp,
310    Sl,
311}
312
313/// Cancel order request.
314#[derive(Debug, Clone, Serialize)]
315pub struct CancelRequest {
316    /// Asset ID.
317    pub a: u32,
318    /// Order ID.
319    pub o: u64,
320}
321
322/// Cancel by client order ID request.
323#[derive(Debug, Clone, Serialize)]
324pub struct CancelByCloidRequest {
325    /// Asset ID.
326    pub asset: u32,
327    /// Client order ID.
328    pub cloid: String,
329}
330
331/// Modify order request.
332#[derive(Debug, Clone, Serialize)]
333pub struct ModifyRequest {
334    /// Order ID.
335    pub oid: u64,
336    /// New order details.
337    pub order: OrderRequest,
338}
339
340/// Subscription response data wrapper.
341#[derive(Debug, Clone, Deserialize)]
342pub struct SubscriptionResponseData {
343    pub method: String,
344    pub subscription: SubscriptionRequest,
345}
346
347/// Inbound WebSocket message from Hyperliquid server.
348#[derive(Debug, Clone, Deserialize)]
349#[serde(remote = "Self", tag = "channel")]
350#[serde(rename_all = "camelCase")]
351pub enum HyperliquidWsMessage {
352    /// Subscription confirmation.
353    SubscriptionResponse { data: SubscriptionResponseData },
354    /// Post request response.
355    Post { data: PostResponse },
356    /// All mid prices.
357    AllMids { data: AllMidsData },
358    /// Aggregate asset contexts across all perp dexes.
359    AllDexsAssetCtxs { data: WsAllDexsAssetCtxsData },
360    /// Notifications.
361    Notification { data: NotificationData },
362    /// Web data.
363    WebData2 { data: serde_json::Value },
364    /// Candlestick data.
365    Candle { data: CandleData },
366    /// Level 2 order book.
367    L2Book { data: WsBookData },
368    /// Trade updates.
369    Trades { data: Vec<WsTradeData> },
370    /// Order updates.
371    OrderUpdates { data: Vec<WsOrderData> },
372    /// User events.
373    UserEvents { data: WsUserEventData },
374    /// Generic user channel (Hyperliquid sends fills/events on this channel).
375    #[serde(rename = "user")]
376    User { data: WsUserEventData },
377    /// User fills.
378    UserFills { data: WsUserFillsData },
379    /// User funding payments.
380    UserFundings { data: WsUserFundingsData },
381    /// User ledger updates.
382    UserNonFundingLedgerUpdates { data: serde_json::Value },
383    /// Active asset context.
384    ActiveAssetCtx { data: WsActiveAssetCtxData },
385    /// Active spot asset context (same data as ActiveAssetCtx, different channel name).
386    ActiveSpotAssetCtx { data: WsActiveAssetCtxData },
387    /// Active asset data.
388    ActiveAssetData { data: WsActiveAssetData },
389    /// TWAP slice fills.
390    UserTwapSliceFills { data: WsUserTwapSliceFillsData },
391    /// TWAP history.
392    UserTwapHistory { data: WsUserTwapHistoryData },
393    /// Best bid/offer.
394    Bbo { data: WsBboData },
395    /// Error response.
396    Error { data: String },
397    /// Pong response.
398    Pong,
399}
400
401impl<'de> Deserialize<'de> for HyperliquidWsMessage {
402    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
403        let raw = Box::<RawValue>::deserialize(deserializer)?;
404        decode_ws_message(raw.get()).map_err(D::Error::custom)
405    }
406}
407
408pub(crate) fn decode_ws_message(text: &str) -> serde_json::Result<HyperliquidWsMessage> {
409    let header: WsMessageHeader = serde_json::from_str(text)?;
410
411    // Tagged enum buffering loses raw numeric tokens; parse active asset data directly
412    if header.channel == "activeAssetData" {
413        let message: WsActiveAssetDataMessage = serde_json::from_str(text)?;
414        return Ok(HyperliquidWsMessage::ActiveAssetData { data: message.data });
415    }
416
417    HyperliquidWsMessage::deserialize(&mut serde_json::Deserializer::from_str(text))
418}
419
420#[derive(Deserialize)]
421struct WsMessageHeader<'a> {
422    #[serde(borrow)]
423    channel: Cow<'a, str>,
424}
425
426#[derive(Deserialize)]
427struct WsActiveAssetDataMessage {
428    data: WsActiveAssetData,
429}
430
431/// Post response data.
432#[derive(Debug, Clone, Deserialize)]
433pub struct PostResponse {
434    pub id: u64,
435    pub response: PostResponsePayload,
436}
437
438/// Post response payload.
439#[derive(Debug, Clone, Deserialize)]
440#[serde(tag = "type")]
441#[serde(rename_all = "lowercase")]
442pub enum PostResponsePayload {
443    Info { payload: serde_json::Value },
444    Action { payload: serde_json::Value },
445    Error { payload: String },
446}
447
448/// All mid prices data.
449#[derive(Debug, Clone, Deserialize)]
450pub struct AllMidsData {
451    pub mids: AHashMap<Ustr, String>,
452}
453
454/// `allDexsAssetCtxs` data payload.
455#[derive(Debug, Clone, Deserialize)]
456pub struct WsAllDexsAssetCtxsData {
457    pub ctxs: Vec<(String, Vec<PerpsAssetCtx>)>,
458}
459
460/// Notification data.
461#[derive(Debug, Clone, Deserialize)]
462pub struct NotificationData {
463    pub notification: String,
464}
465
466/// Candlestick data.
467#[derive(Debug, Clone, Deserialize)]
468pub struct CandleData {
469    /// Open time (millis).
470    pub t: u64,
471    /// Close time (millis).
472    #[serde(rename = "T")]
473    pub close_time: u64,
474    /// Symbol.
475    pub s: Ustr,
476    /// Interval.
477    pub i: Ustr,
478    /// Open price.
479    #[serde(deserialize_with = "deserialize_decimal_from_str")]
480    pub o: Decimal,
481    /// Close price.
482    #[serde(deserialize_with = "deserialize_decimal_from_str")]
483    pub c: Decimal,
484    /// High price.
485    #[serde(deserialize_with = "deserialize_decimal_from_str")]
486    pub h: Decimal,
487    /// Low price.
488    #[serde(deserialize_with = "deserialize_decimal_from_str")]
489    pub l: Decimal,
490    /// Volume.
491    #[serde(deserialize_with = "deserialize_decimal_from_str")]
492    pub v: Decimal,
493    /// Number of trades.
494    pub n: u32,
495}
496
497/// WebSocket book data.
498#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct WsBookData {
500    pub coin: Ustr,
501    pub levels: [Vec<WsLevelData>; 2], // [bids, asks]
502    pub time: u64,
503}
504
505/// WebSocket level data.
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct WsLevelData {
508    /// Price.
509    #[serde(
510        deserialize_with = "deserialize_decimal_from_str",
511        serialize_with = "serialize_decimal_as_str"
512    )]
513    pub px: Decimal,
514    /// Size.
515    #[serde(
516        deserialize_with = "deserialize_decimal_from_str",
517        serialize_with = "serialize_decimal_as_str"
518    )]
519    pub sz: Decimal,
520    /// Number of orders.
521    pub n: u32,
522}
523
524/// WebSocket trade data.
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct WsTradeData {
527    pub coin: Ustr,
528    pub side: HyperliquidSide,
529    #[serde(
530        deserialize_with = "deserialize_decimal_from_str",
531        serialize_with = "serialize_decimal_as_str"
532    )]
533    pub px: Decimal,
534    #[serde(
535        deserialize_with = "deserialize_decimal_from_str",
536        serialize_with = "serialize_decimal_as_str"
537    )]
538    pub sz: Decimal,
539    pub hash: String,
540    pub time: u64,
541    pub tid: u64,
542    pub users: [String; 2], // [buyer, seller]
543}
544
545/// WebSocket order data.
546#[derive(Debug, Clone, Deserialize)]
547pub struct WsOrderData {
548    pub order: WsBasicOrderData,
549    pub status: HyperliquidOrderStatusEnum,
550    #[serde(rename = "statusTimestamp")]
551    pub status_timestamp: u64,
552}
553
554/// Basic order data.
555#[derive(Debug, Clone, Deserialize)]
556pub struct WsBasicOrderData {
557    pub coin: Ustr,
558    pub side: HyperliquidSide,
559    #[serde(rename = "limitPx", deserialize_with = "deserialize_decimal_from_str")]
560    pub limit_px: Decimal,
561    #[serde(deserialize_with = "deserialize_decimal_from_str")]
562    pub sz: Decimal,
563    pub oid: u64,
564    pub timestamp: u64,
565    #[serde(rename = "origSz", deserialize_with = "deserialize_decimal_from_str")]
566    pub orig_sz: Decimal,
567    pub cloid: Option<String>,
568    pub tif: Option<HyperliquidTimeInForce>,
569    #[serde(rename = "reduceOnly")]
570    pub reduce_only: Option<bool>,
571    /// Trigger price for conditional orders (stop/take-profit).
572    #[serde(
573        rename = "triggerPx",
574        default,
575        deserialize_with = "deserialize_optional_decimal_from_str"
576    )]
577    pub trigger_px: Option<Decimal>,
578    /// Whether this is a market or limit trigger order.
579    #[serde(rename = "isMarket")]
580    pub is_market: Option<bool>,
581    /// Take-profit or stop-loss indicator.
582    pub tpsl: Option<HyperliquidTpSl>,
583    /// Whether the trigger has been activated.
584    #[serde(rename = "triggerActivated")]
585    pub trigger_activated: Option<bool>,
586    /// Trailing stop parameters if applicable.
587    #[serde(rename = "trailingStop")]
588    pub trailing_stop: Option<WsTrailingStopData>,
589    /// Venue order type label (for example `"Stop Market"`), present on REST order rows
590    /// such as `frontendOpenOrders`, which omit `tpsl` and `isMarket`.
591    #[serde(rename = "orderType", default)]
592    pub order_type: Option<String>,
593}
594
595/// Trailing stop offset type.
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
597#[serde(rename_all = "camelCase")]
598pub enum TrailingOffsetType {
599    /// Price offset.
600    Price,
601    /// Percentage offset.
602    Percentage,
603    /// Basis points offset.
604    BasisPoints,
605}
606
607impl TrailingOffsetType {
608    /// Format the offset value with the appropriate unit.
609    pub fn format_offset(&self, offset: &str) -> String {
610        match self {
611            Self::Price => offset.to_string(),
612            Self::Percentage => format!("{offset}%"),
613            Self::BasisPoints => format!("{offset} bps"),
614        }
615    }
616}
617
618/// Trailing stop data from WebSocket.
619#[derive(Debug, Clone, Deserialize)]
620pub struct WsTrailingStopData {
621    /// Trailing offset value.
622    #[serde(deserialize_with = "deserialize_decimal_from_str")]
623    pub offset: Decimal,
624    /// Offset type.
625    #[serde(rename = "offsetType")]
626    pub offset_type: TrailingOffsetType,
627    /// Current callback price (highest/lowest price reached).
628    #[serde(
629        rename = "callbackPrice",
630        default,
631        deserialize_with = "deserialize_optional_decimal_from_str"
632    )]
633    pub callback_price: Option<Decimal>,
634}
635
636/// WebSocket user event data.
637#[derive(Debug, Clone, Deserialize)]
638#[serde(untagged)]
639pub enum WsUserEventData {
640    Fills {
641        fills: Vec<WsFillData>,
642    },
643    Funding {
644        funding: WsUserFundingData,
645    },
646    Liquidation {
647        liquidation: WsLiquidationData,
648    },
649    NonUserCancel {
650        #[serde(rename = "nonUserCancel")]
651        non_user_cancel: Vec<WsNonUserCancelData>,
652    },
653    /// Trigger order activated (moved from pending to active).
654    TriggerActivated {
655        #[serde(rename = "triggerActivated")]
656        trigger_activated: WsTriggerActivatedData,
657    },
658    /// Trigger order executed (trigger price reached, order placed).
659    TriggerTriggered {
660        #[serde(rename = "triggerTriggered")]
661        trigger_triggered: WsTriggerTriggeredData,
662    },
663}
664
665/// WebSocket fill data.
666#[derive(Debug, Clone, Deserialize)]
667pub struct WsFillData {
668    pub coin: Ustr,
669    #[serde(deserialize_with = "deserialize_decimal_from_str")]
670    pub px: Decimal,
671    #[serde(deserialize_with = "deserialize_decimal_from_str")]
672    pub sz: Decimal,
673    pub side: HyperliquidSide,
674    pub time: u64,
675    #[serde(
676        rename = "startPosition",
677        deserialize_with = "deserialize_decimal_from_str"
678    )]
679    pub start_position: Decimal,
680    pub dir: HyperliquidFillDirection,
681    #[serde(
682        rename = "closedPnl",
683        deserialize_with = "deserialize_decimal_from_str"
684    )]
685    pub closed_pnl: Decimal,
686    pub hash: String,
687    pub oid: u64,
688    pub crossed: bool,
689    #[serde(deserialize_with = "deserialize_decimal_from_str")]
690    pub fee: Decimal,
691    pub tid: u64,
692    #[serde(default)]
693    pub liquidation: Option<FillLiquidationData>,
694    #[serde(rename = "feeToken")]
695    pub fee_token: Ustr,
696    #[serde(
697        rename = "builderFee",
698        default,
699        deserialize_with = "deserialize_optional_decimal_from_str"
700    )]
701    pub builder_fee: Option<Decimal>,
702    /// Client order ID (hex string with 0x prefix).
703    pub cloid: Option<String>,
704    /// TWAP order ID if this fill is part of a TWAP order.
705    #[serde(rename = "twapId")]
706    pub twap_id: Option<serde_json::Value>,
707}
708
709/// Fill liquidation data.
710#[derive(Debug, Clone, Deserialize)]
711pub struct FillLiquidationData {
712    #[serde(rename = "liquidatedUser")]
713    pub liquidated_user: Option<String>,
714    #[serde(rename = "markPx", deserialize_with = "deserialize_decimal_from_str")]
715    pub mark_px: Decimal,
716    pub method: HyperliquidLiquidationMethod,
717}
718
719/// WebSocket user funding data.
720#[derive(Debug, Clone, Deserialize)]
721pub struct WsUserFundingData {
722    pub time: u64,
723    pub coin: Ustr,
724    #[serde(deserialize_with = "deserialize_decimal_from_str")]
725    pub usdc: Decimal,
726    #[serde(deserialize_with = "deserialize_decimal_from_str")]
727    pub szi: Decimal,
728    #[serde(
729        rename = "fundingRate",
730        deserialize_with = "deserialize_decimal_from_str"
731    )]
732    pub funding_rate: Decimal,
733}
734
735/// WebSocket liquidation data.
736#[derive(Debug, Clone, Deserialize)]
737pub struct WsLiquidationData {
738    pub lid: u64,
739    pub liquidator: String,
740    pub liquidated_user: String,
741    #[serde(deserialize_with = "deserialize_decimal_from_str")]
742    pub liquidated_ntl_pos: Decimal,
743    #[serde(deserialize_with = "deserialize_decimal_from_str")]
744    pub liquidated_account_value: Decimal,
745}
746
747/// WebSocket non-user cancel data.
748#[derive(Debug, Clone, Deserialize)]
749pub struct WsNonUserCancelData {
750    pub coin: Ustr,
751    pub oid: u64,
752}
753
754/// Trigger order activated event data.
755#[derive(Debug, Clone, Deserialize)]
756pub struct WsTriggerActivatedData {
757    pub coin: Ustr,
758    pub oid: u64,
759    pub time: u64,
760    #[serde(
761        rename = "triggerPx",
762        deserialize_with = "deserialize_decimal_from_str"
763    )]
764    pub trigger_px: Decimal,
765    pub tpsl: HyperliquidTpSl,
766}
767
768/// Trigger order triggered event data.
769#[derive(Debug, Clone, Deserialize)]
770pub struct WsTriggerTriggeredData {
771    pub coin: Ustr,
772    pub oid: u64,
773    pub time: u64,
774    #[serde(
775        rename = "triggerPx",
776        deserialize_with = "deserialize_decimal_from_str"
777    )]
778    pub trigger_px: Decimal,
779    #[serde(rename = "marketPx", deserialize_with = "deserialize_decimal_from_str")]
780    pub market_px: Decimal,
781    pub tpsl: HyperliquidTpSl,
782    /// Order ID of the resulting market/limit order after trigger.
783    #[serde(rename = "resultingOid")]
784    pub resulting_oid: Option<u64>,
785}
786
787/// WebSocket user fills data.
788#[derive(Debug, Clone, Deserialize)]
789pub struct WsUserFillsData {
790    #[serde(rename = "isSnapshot")]
791    pub is_snapshot: Option<bool>,
792    pub user: String,
793    pub fills: Vec<WsFillData>,
794}
795
796/// WebSocket user fundings data.
797#[derive(Debug, Clone, Deserialize)]
798pub struct WsUserFundingsData {
799    #[serde(rename = "isSnapshot")]
800    pub is_snapshot: Option<bool>,
801    pub user: String,
802    pub fundings: Vec<WsUserFundingData>,
803}
804
805/// WebSocket active asset context data.
806#[derive(Debug, Clone, Deserialize)]
807#[serde(untagged)]
808pub enum WsActiveAssetCtxData {
809    Perp { coin: Ustr, ctx: PerpsAssetCtx },
810    Spot { coin: Ustr, ctx: SpotAssetCtx },
811}
812
813/// Shared asset context fields.
814#[derive(Debug, Clone, Deserialize)]
815pub struct SharedAssetCtx {
816    #[serde(
817        rename = "dayNtlVlm",
818        deserialize_with = "deserialize_decimal_from_str"
819    )]
820    pub day_ntl_vlm: Decimal,
821    #[serde(
822        rename = "prevDayPx",
823        deserialize_with = "deserialize_decimal_from_str"
824    )]
825    pub prev_day_px: Decimal,
826    #[serde(rename = "markPx", deserialize_with = "deserialize_decimal_from_str")]
827    pub mark_px: Decimal,
828    #[serde(
829        rename = "midPx",
830        default,
831        deserialize_with = "deserialize_optional_decimal_from_str"
832    )]
833    pub mid_px: Option<Decimal>,
834    #[serde(rename = "impactPxs")]
835    pub impact_pxs: Option<Vec<Decimal>>,
836    #[serde(
837        rename = "dayBaseVlm",
838        default,
839        deserialize_with = "deserialize_optional_decimal_from_str"
840    )]
841    pub day_base_vlm: Option<Decimal>,
842}
843
844/// Perps asset context.
845#[derive(Debug, Clone, Deserialize)]
846pub struct PerpsAssetCtx {
847    #[serde(flatten)]
848    pub shared: SharedAssetCtx,
849    #[serde(deserialize_with = "deserialize_decimal_from_str")]
850    pub funding: Decimal,
851    #[serde(
852        rename = "openInterest",
853        deserialize_with = "deserialize_decimal_from_str"
854    )]
855    pub open_interest: Decimal,
856    #[serde(rename = "oraclePx", deserialize_with = "deserialize_decimal_from_str")]
857    pub oracle_px: Decimal,
858    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
859    pub premium: Option<Decimal>,
860}
861
862/// Spot asset context.
863#[derive(Debug, Clone, Deserialize)]
864pub struct SpotAssetCtx {
865    #[serde(flatten)]
866    pub shared: SharedAssetCtx,
867    #[serde(
868        rename = "circulatingSupply",
869        deserialize_with = "deserialize_decimal_from_str"
870    )]
871    pub circulating_supply: Decimal,
872}
873
874/// WebSocket active asset data.
875#[derive(Debug, Clone, Deserialize)]
876pub struct WsActiveAssetData {
877    pub user: String,
878    pub coin: Ustr,
879    pub leverage: LeverageInfo,
880    #[serde(rename = "maxTradeSzs", deserialize_with = "deserialize_trade_limits")]
881    pub max_trade_szs: [Decimal; 2],
882    #[serde(
883        rename = "availableToTrade",
884        deserialize_with = "deserialize_trade_limits"
885    )]
886    pub available_to_trade: [Decimal; 2],
887}
888
889fn deserialize_trade_limits<'de, D: Deserializer<'de>>(
890    deserializer: D,
891) -> Result<[Decimal; 2], D::Error> {
892    <[DecimalToken; 2]>::deserialize(deserializer).map(|values| values.map(|value| value.0))
893}
894
895#[derive(Deserialize)]
896struct DecimalToken(#[serde(deserialize_with = "decimal::deserialize_json")] Decimal);
897
898/// WebSocket TWAP slice fills data.
899#[derive(Debug, Clone, Deserialize)]
900pub struct WsUserTwapSliceFillsData {
901    #[serde(rename = "isSnapshot")]
902    pub is_snapshot: Option<bool>,
903    pub user: String,
904    #[serde(rename = "twapSliceFills")]
905    pub twap_slice_fills: Vec<WsTwapSliceFillData>,
906}
907
908/// TWAP slice fill data.
909#[derive(Debug, Clone, Deserialize)]
910pub struct WsTwapSliceFillData {
911    pub fill: WsFillData,
912    #[serde(rename = "twapId")]
913    pub twap_id: u64,
914}
915
916/// WebSocket TWAP history data.
917#[derive(Debug, Clone, Deserialize)]
918pub struct WsUserTwapHistoryData {
919    #[serde(rename = "isSnapshot")]
920    pub is_snapshot: Option<bool>,
921    pub user: String,
922    pub history: Vec<WsTwapHistoryData>,
923}
924
925/// TWAP history data.
926#[derive(Debug, Clone, Deserialize)]
927pub struct WsTwapHistoryData {
928    pub state: TwapStateData,
929    pub status: TwapStatusData,
930    pub time: u64,
931    #[serde(default, rename = "twapId")]
932    pub twap_id: Option<u64>,
933}
934
935/// TWAP state data.
936#[derive(Debug, Clone, Deserialize)]
937pub struct TwapStateData {
938    pub coin: Ustr,
939    pub user: String,
940    pub side: HyperliquidSide,
941    /// Venue may send a JSON string or number.
942    #[serde(deserialize_with = "deserialize_decimal")]
943    pub sz: Decimal,
944    #[serde(rename = "executedSz", deserialize_with = "deserialize_decimal")]
945    pub executed_sz: Decimal,
946    #[serde(rename = "executedNtl", deserialize_with = "deserialize_decimal")]
947    pub executed_ntl: Decimal,
948    pub minutes: u32,
949    #[serde(rename = "reduceOnly")]
950    pub reduce_only: bool,
951    pub randomize: bool,
952    pub timestamp: u64,
953}
954
955/// TWAP status data.
956#[derive(Debug, Clone, Deserialize)]
957pub struct TwapStatusData {
958    pub status: HyperliquidTwapStatus,
959    /// Present when `status` is `error`; otherwise often omitted.
960    #[serde(default)]
961    pub description: String,
962}
963
964/// WebSocket BBO data.
965#[derive(Debug, Clone, Deserialize)]
966pub struct WsBboData {
967    pub coin: Ustr,
968    pub time: u64,
969    pub bbo: [Option<WsLevelData>; 2], // [bid, ask]
970}
971
972#[cfg(test)]
973mod tests {
974    use rstest::rstest;
975    use rust_decimal_macros::dec;
976    use serde_json;
977
978    use super::*;
979    use crate::common::enums::HyperliquidLeverageType;
980
981    #[rstest]
982    fn test_cancel_action_serialization(
983        #[values(None, Some(false), Some(true))] fast: Option<bool>,
984        #[values(false, true)] by_cloid: bool,
985    ) {
986        let (action, name) = if by_cloid {
987            (
988                ActionRequest::CancelByCloid {
989                    cancels: vec![CancelByCloidRequest {
990                        asset: 7,
991                        cloid: "0x0123456789abcdef0123456789abcdef".to_string(),
992                    }],
993                    fast,
994                },
995                "ws_cancel_by_cloid",
996            )
997        } else {
998            (
999                ActionRequest::Cancel {
1000                    cancels: vec![CancelRequest { a: 7, o: 12345 }],
1001                    fast,
1002                },
1003                "cancel",
1004            )
1005        };
1006
1007        let fixtures: serde_json::Value =
1008            serde_json::from_str(include_str!("../../test_data/cancel_actions.json")).unwrap();
1009        let expected = &fixtures[name][if fast == Some(true) { "fast" } else { "normal" }];
1010        let json = serde_json::to_value(&action).unwrap();
1011        let msgpack = rmp_serde::to_vec_named(&action).unwrap();
1012
1013        assert_eq!(json, expected["json"]);
1014        assert_eq!(
1015            nautilus_core::hex::encode(msgpack),
1016            expected["msgpack"].as_str().unwrap()
1017        );
1018    }
1019
1020    #[rstest]
1021    #[case("cross", HyperliquidLeverageType::Cross)]
1022    #[case("isolated", HyperliquidLeverageType::Isolated)]
1023    fn test_ws_active_asset_data_preserves_decimals(
1024        #[case] leverage_type: &str,
1025        #[case] expected_type: HyperliquidLeverageType,
1026        #[values("number", "string", "scientific")] encoding: &str,
1027        #[values(false, true)] serde_entry: bool,
1028        #[values(false, true)] channel_last: bool,
1029    ) {
1030        let max_trade_szs = [
1031            dec!(9007199254740993.01),
1032            dec!(0.0000000000000000000000000001),
1033        ];
1034        let available_to_trade = [
1035            dec!(0.1234567890123456789012345678),
1036            dec!(1234567890.1234567890123456789),
1037        ];
1038        let mut json = include_str!("../../test_data/ws_active_asset_data_decimal.json")
1039            .replace("\"cross\"", &format!("\"{leverage_type}\""));
1040
1041        if encoding != "number" {
1042            for value in max_trade_szs.into_iter().chain(available_to_trade) {
1043                let text = value.to_string();
1044
1045                let encoded = match encoding {
1046                    "string" => format!("\"{text}\""),
1047                    "scientific" => format!("{text}e0"),
1048                    _ => unreachable!(),
1049                };
1050
1051                json = json.replace(&text, &encoded);
1052            }
1053        }
1054
1055        if channel_last {
1056            json = json
1057                .replace("  \"channel\": \"activeAssetData\",\n", "")
1058                .trim_end()
1059                .strip_suffix('}')
1060                .unwrap()
1061                .to_string()
1062                + ",\"channel\":\"activeAssetData\"}";
1063        }
1064
1065        let msg = if serde_entry {
1066            serde_json::from_str::<HyperliquidWsMessage>(&json).unwrap()
1067        } else {
1068            decode_ws_message(&json).unwrap()
1069        };
1070
1071        let HyperliquidWsMessage::ActiveAssetData { data } = msg else {
1072            panic!("Expected ActiveAssetData message");
1073        };
1074
1075        assert_eq!(data.user, "0x0000000000000000000000000000000000000001");
1076        assert_eq!(data.coin, "BTC");
1077        assert_eq!(data.leverage.leverage_type, expected_type);
1078        assert_eq!(data.leverage.value, 7);
1079        assert_eq!(data.max_trade_szs, max_trade_szs);
1080        assert_eq!(data.available_to_trade, available_to_trade);
1081    }
1082
1083    #[rstest]
1084    #[case(
1085        "[1]",
1086        serde_json::error::Category::Data,
1087        "invalid length 1, expected an array of length 2"
1088    )]
1089    #[case(
1090        "[1, 2, 3]",
1091        serde_json::error::Category::Syntax,
1092        "trailing characters"
1093    )]
1094    #[case(
1095        "[null, 2]",
1096        serde_json::error::Category::Data,
1097        "invalid decimal significand"
1098    )]
1099    #[case(
1100        "[\"not-a-number\", 2]",
1101        serde_json::error::Category::Data,
1102        "invalid decimal exponent"
1103    )]
1104    #[case(
1105        "[\"\", 2]",
1106        serde_json::error::Category::Data,
1107        "invalid decimal significand"
1108    )]
1109    #[case(
1110        "[true, 2]",
1111        serde_json::error::Category::Data,
1112        "invalid decimal exponent"
1113    )]
1114    #[case(
1115        "[{}, 2]",
1116        serde_json::error::Category::Data,
1117        "invalid decimal significand"
1118    )]
1119    #[case(
1120        "[0.00000000000000000000000000001, 2]",
1121        serde_json::error::Category::Data,
1122        "Number has a high precision that can not be represented."
1123    )]
1124    fn test_ws_active_asset_data_rejects_invalid_trade_limits(
1125        #[values("maxTradeSzs", "availableToTrade")] field: &str,
1126        #[case] values: &str,
1127        #[case] category: serde_json::error::Category,
1128        #[case] expected: &str,
1129    ) {
1130        let mut json =
1131            include_str!("../../test_data/ws_active_asset_data_decimal.json").to_string();
1132        let key = format!("\"{field}\": ");
1133        let start = json.find(&key).unwrap() + key.len();
1134        let end = start + json[start..].find(']').unwrap() + 1;
1135        json.replace_range(start..end, values);
1136        let error = decode_ws_message(&json).unwrap_err();
1137
1138        assert_eq!(error.classify(), category);
1139        assert_eq!(
1140            error.to_string().split(" at line ").next().unwrap(),
1141            expected
1142        );
1143    }
1144
1145    #[rstest]
1146    #[case(r#"{"channel":"pong"}"#)]
1147    #[case(r#"{"channel":"p\u006fng"}"#)]
1148    fn test_decode_ws_message_pong(#[case] json: &str) {
1149        let message = decode_ws_message(json).unwrap();
1150
1151        assert!(matches!(message, HyperliquidWsMessage::Pong));
1152    }
1153
1154    #[rstest]
1155    #[case("{}", "missing field `channel`")]
1156    #[case(r#"{"channel":"activeAssetData"}"#, "missing field `data`")]
1157    #[case(
1158        r#"{"channel":"pong","channel":"activeAssetData"}"#,
1159        "duplicate field `channel`"
1160    )]
1161    fn test_decode_ws_message_rejects_invalid_envelope(#[case] json: &str, #[case] expected: &str) {
1162        let error = decode_ws_message(json).unwrap_err();
1163
1164        assert_eq!(error.classify(), serde_json::error::Category::Data);
1165        assert_eq!(
1166            error.to_string().split(" at line ").next().unwrap(),
1167            expected
1168        );
1169    }
1170
1171    #[rstest]
1172    fn test_ws_active_asset_ctx_preserves_decimals() {
1173        let json = include_str!("../../test_data/ws_active_asset_ctx_decimal.json");
1174        let msg: HyperliquidWsMessage = serde_json::from_str(json).unwrap();
1175
1176        let HyperliquidWsMessage::ActiveAssetCtx {
1177            data: WsActiveAssetCtxData::Perp { coin, ctx },
1178        } = msg
1179        else {
1180            panic!("Expected perpetual ActiveAssetCtx message");
1181        };
1182
1183        assert_eq!(coin, "BTC");
1184        assert_eq!(ctx.shared.day_ntl_vlm, dec!(1234567890.1234567890123456789));
1185        assert_eq!(ctx.shared.prev_day_px, dec!(49000.12345678901234567890123));
1186        assert_eq!(ctx.shared.mark_px, dec!(50000.12345678901234567890123));
1187        assert_eq!(ctx.shared.mid_px, Some(dec!(50001.12345678901234567890123)));
1188        assert_eq!(
1189            ctx.shared.impact_pxs,
1190            Some(vec![
1191                dec!(50002.12345678901234567890123),
1192                dec!(50003.12345678901234567890123),
1193            ])
1194        );
1195        assert_eq!(
1196            ctx.shared.day_base_vlm,
1197            Some(dec!(12345.12345678901234567890123))
1198        );
1199        assert_eq!(ctx.funding, dec!(-0.0000000000000000000000000001));
1200        assert_eq!(ctx.open_interest, dec!(9007199254740993.01));
1201        assert_eq!(ctx.oracle_px, dec!(50004.12345678901234567890123));
1202        assert_eq!(ctx.premium, Some(dec!(0.0000000000000000000000000002)));
1203    }
1204
1205    #[rstest]
1206    #[case(true)]
1207    #[case(false)]
1208    fn test_ws_active_asset_ctx_optional_decimals(#[case] explicit_null: bool) {
1209        let mut json: serde_json::Value = serde_json::from_str(include_str!(
1210            "../../test_data/ws_active_asset_ctx_decimal.json"
1211        ))
1212        .unwrap();
1213        let ctx = json["data"]["ctx"].as_object_mut().unwrap();
1214
1215        for field in ["midPx", "impactPxs", "dayBaseVlm", "premium"] {
1216            if explicit_null {
1217                ctx.insert(field.to_string(), serde_json::Value::Null);
1218            } else {
1219                ctx.remove(field);
1220            }
1221        }
1222
1223        let msg: HyperliquidWsMessage = serde_json::from_value(json).unwrap();
1224
1225        let HyperliquidWsMessage::ActiveAssetCtx {
1226            data: WsActiveAssetCtxData::Perp { ctx, .. },
1227        } = msg
1228        else {
1229            panic!("Expected perpetual ActiveAssetCtx message");
1230        };
1231
1232        assert_eq!(ctx.shared.mid_px, None);
1233        assert_eq!(ctx.shared.impact_pxs, None);
1234        assert_eq!(ctx.shared.day_base_vlm, None);
1235        assert_eq!(ctx.premium, None);
1236    }
1237
1238    #[rstest]
1239    fn test_ws_active_spot_asset_ctx_preserves_decimals() {
1240        let json = include_str!("../../test_data/ws_active_spot_asset_ctx_decimal.json");
1241        let msg: HyperliquidWsMessage = serde_json::from_str(json).unwrap();
1242
1243        let HyperliquidWsMessage::ActiveSpotAssetCtx {
1244            data: WsActiveAssetCtxData::Spot { coin, ctx },
1245        } = msg
1246        else {
1247            panic!("Expected spot ActiveAssetCtx message");
1248        };
1249
1250        assert_eq!(coin, "@107");
1251        assert_eq!(ctx.shared.day_ntl_vlm, dec!(1234567890.1234567890123456789));
1252        assert_eq!(ctx.shared.prev_day_px, dec!(31.12345678901234567890123456));
1253        assert_eq!(ctx.shared.mark_px, dec!(32.12345678901234567890123456));
1254        assert_eq!(ctx.shared.mid_px, None);
1255        assert_eq!(ctx.shared.impact_pxs, None);
1256        assert_eq!(ctx.shared.day_base_vlm, None);
1257        assert_eq!(ctx.circulating_supply, dec!(9007199254740993.01));
1258    }
1259
1260    #[rstest]
1261    fn test_signature_data_serialization_and_debug_redaction() {
1262        let signature = SignatureData {
1263            r: SecretString::from("0xsignature-r"),
1264            s: SecretString::from("0xsignature-s"),
1265            v: SecretString::from("0x1b"),
1266        };
1267        let wire = serde_json::to_value(&signature).unwrap();
1268        let debug = format!("{signature:?}");
1269
1270        assert_eq!(wire["r"], "0xsignature-r");
1271        assert_eq!(wire["s"], "0xsignature-s");
1272        assert_eq!(wire["v"], "0x1b");
1273        assert_eq!(debug.matches(REDACTED).count(), 3);
1274        assert!(!debug.contains("0xsignature-r"));
1275        assert!(!debug.contains("0xsignature-s"));
1276        assert!(!debug.contains("0x1b"));
1277    }
1278
1279    #[rstest]
1280    fn test_subscription_request_serialization() {
1281        let sub = SubscriptionRequest::L2Book {
1282            coin: Ustr::from("BTC"),
1283            n_sig_figs: Some(5),
1284            mantissa: None,
1285        };
1286
1287        let json = serde_json::to_string(&sub).unwrap();
1288        assert!(json.contains(r#""type":"l2Book""#));
1289        assert!(json.contains(r#""coin":"BTC""#));
1290    }
1291
1292    #[rstest]
1293    fn test_hyperliquid_ws_request_serialization() {
1294        let req = HyperliquidWsRequest::Subscribe {
1295            subscription: SubscriptionRequest::Trades {
1296                coin: Ustr::from("ETH"),
1297            },
1298        };
1299
1300        let json = serde_json::to_string(&req).unwrap();
1301        assert!(json.contains(r#""method":"subscribe""#));
1302        assert!(json.contains(r#""type":"trades""#));
1303    }
1304
1305    #[rstest]
1306    fn test_order_request_serialization() {
1307        let order = OrderRequest {
1308            a: 0,    // BTC asset ID
1309            b: true, // buy
1310            p: "50000.0".to_string(),
1311            s: "0.1".to_string(),
1312            r: false,
1313            t: OrderTypeRequest::Limit {
1314                tif: TimeInForceRequest::Gtc,
1315            },
1316            c: Some("client-123".to_string()),
1317        };
1318
1319        let json = serde_json::to_string(&order).unwrap();
1320        assert!(json.contains(r#""a":0"#));
1321        assert!(json.contains(r#""b":true"#));
1322        assert!(json.contains(r#""p":"50000.0""#));
1323    }
1324
1325    #[rstest]
1326    fn test_ws_trade_data_deserialization() {
1327        let json = r#"{
1328            "coin": "BTC",
1329            "side": "B",
1330            "px": "50000.0",
1331            "sz": "0.1",
1332            "hash": "0x123",
1333            "time": 1234567890,
1334            "tid": 12345,
1335            "users": ["0xabc", "0xdef"]
1336        }"#;
1337
1338        let trade: WsTradeData = serde_json::from_str(json).unwrap();
1339        assert_eq!(trade.coin, "BTC");
1340        assert_eq!(trade.side, HyperliquidSide::Buy);
1341        assert_eq!(trade.px, dec!(50000.0));
1342    }
1343
1344    #[rstest]
1345    fn test_ws_book_data_deserialization() {
1346        let json = r#"{
1347            "coin": "ETH",
1348            "levels": [
1349                [{"px": "3000.0", "sz": "1.0", "n": 1}],
1350                [{"px": "3001.0", "sz": "2.0", "n": 2}]
1351            ],
1352            "time": 1234567890
1353        }"#;
1354
1355        let book: WsBookData = serde_json::from_str(json).unwrap();
1356        assert_eq!(book.coin, "ETH");
1357        assert_eq!(book.levels[0].len(), 1);
1358        assert_eq!(book.levels[1].len(), 1);
1359    }
1360
1361    #[rstest]
1362    fn test_ws_trailing_stop_data_deserialization() {
1363        let json = r#"{
1364            "offset": "100.0",
1365            "offsetType": "price",
1366            "callbackPrice": "50000.0"
1367        }"#;
1368
1369        let data: WsTrailingStopData = serde_json::from_str(json).unwrap();
1370        assert_eq!(data.offset, dec!(100.0));
1371        assert_eq!(data.offset_type, TrailingOffsetType::Price);
1372        assert_eq!(data.callback_price.unwrap(), dec!(50000.0));
1373    }
1374
1375    #[rstest]
1376    fn test_ws_trigger_activated_data_deserialization() {
1377        let json = r#"{
1378            "coin": "BTC",
1379            "oid": 12345,
1380            "time": 1704470400000,
1381            "triggerPx": "50000.0",
1382            "tpsl": "sl"
1383        }"#;
1384
1385        let data: WsTriggerActivatedData = serde_json::from_str(json).unwrap();
1386        assert_eq!(data.coin, Ustr::from("BTC"));
1387        assert_eq!(data.oid, 12345);
1388        assert_eq!(data.trigger_px, dec!(50000.0));
1389        assert_eq!(data.tpsl, HyperliquidTpSl::Sl);
1390        assert_eq!(data.time, 1704470400000);
1391    }
1392
1393    #[rstest]
1394    fn test_ws_trigger_triggered_data_deserialization() {
1395        let json = r#"{
1396            "coin": "ETH",
1397            "oid": 67890,
1398            "time": 1704470500000,
1399            "triggerPx": "3000.0",
1400            "marketPx": "3001.0",
1401            "tpsl": "tp",
1402            "resultingOid": 99999
1403        }"#;
1404
1405        let data: WsTriggerTriggeredData = serde_json::from_str(json).unwrap();
1406        assert_eq!(data.coin, Ustr::from("ETH"));
1407        assert_eq!(data.oid, 67890);
1408        assert_eq!(data.trigger_px, dec!(3000.0));
1409        assert_eq!(data.market_px, dec!(3001.0));
1410        assert_eq!(data.tpsl, HyperliquidTpSl::Tp);
1411        assert_eq!(data.resulting_oid, Some(99999));
1412    }
1413
1414    #[rstest]
1415    fn test_ws_fill_data_deserialization_with_cloid_and_twap() {
1416        let json = r#"{
1417            "coin": "@107",
1418            "px": "31.737",
1419            "sz": "0.31",
1420            "side": "B",
1421            "time": 1769920606068,
1422            "startPosition": "0.0",
1423            "dir": "Buy",
1424            "closedPnl": "0.0",
1425            "hash": "0xc731e7561e5334a0c8ab043472ce7d01d400ff3bb95653726afa92a8dd570e8b",
1426            "oid": 308086083674,
1427            "crossed": true,
1428            "fee": "0.00021699",
1429            "tid": 812806034449156,
1430            "cloid": "0xd211f1c27288259290850338d22132a0",
1431            "feeToken": "HYPE",
1432            "twapId": null
1433        }"#;
1434
1435        let fill: WsFillData = serde_json::from_str(json).unwrap();
1436        assert_eq!(fill.coin, "@107");
1437        assert_eq!(fill.px, dec!(31.737));
1438        assert_eq!(fill.sz, dec!(0.31));
1439        assert_eq!(fill.side, HyperliquidSide::Buy);
1440        assert_eq!(fill.oid, 308086083674);
1441        assert!(fill.crossed);
1442        assert_eq!(fill.fee, dec!(0.00021699));
1443        assert_eq!(fill.fee_token, "HYPE");
1444        assert_eq!(
1445            fill.cloid,
1446            Some("0xd211f1c27288259290850338d22132a0".to_string())
1447        );
1448        assert!(fill.twap_id.is_none() || fill.twap_id == Some(serde_json::Value::Null));
1449    }
1450
1451    #[rstest]
1452    fn test_ws_user_fills_message_deserialization() {
1453        let json = r#"{"channel":"user","data":{"fills":[{"coin":"@107","px":"31.737","sz":"0.31","side":"B","time":1769920606068,"startPosition":"0.0","dir":"Buy","closedPnl":"0.0","hash":"0xc731e7561e5334a0c8ab043472ce7d01d400ff3bb95653726afa92a8dd570e8b","oid":308086083674,"crossed":true,"fee":"0.00021699","tid":812806034449156,"cloid":"0xd211f1c27288259290850338d22132a0","feeToken":"HYPE","twapId":null}]}}"#;
1454
1455        let msg: HyperliquidWsMessage = serde_json::from_str(json).unwrap();
1456
1457        match msg {
1458            HyperliquidWsMessage::User { data } => match data {
1459                WsUserEventData::Fills { fills } => {
1460                    assert_eq!(fills.len(), 1);
1461                    let fill = &fills[0];
1462                    assert_eq!(fill.coin, "@107");
1463                    assert_eq!(fill.px, dec!(31.737));
1464                    assert_eq!(
1465                        fill.cloid,
1466                        Some("0xd211f1c27288259290850338d22132a0".to_string())
1467                    );
1468                }
1469                _ => panic!("Expected Fills variant"),
1470            },
1471            _ => panic!("Expected User channel message"),
1472        }
1473    }
1474
1475    #[rstest]
1476    fn test_ws_user_fills_message_with_builder_fee() {
1477        // Real message from production that was failing
1478        let json = r#"{"channel":"user","data":{"fills":[{"coin":"BTC","px":"79146.0","sz":"0.001","side":"A","time":1769940855551,"startPosition":"0.00093","dir":"Long > Short","closedPnl":"0.046128","hash":"0x5f8b9c337a197c4061050434769793020e020019151c9b1203544786391d562b","oid":308254271324,"crossed":false,"fee":"0.019785","builderFee":"0.007914","tid":404237815023429,"cloid":"0x50663504b0f4fedea00080176229d94f","feeToken":"USDC","twapId":null}]}}"#;
1479
1480        let msg: HyperliquidWsMessage = serde_json::from_str(json).unwrap();
1481
1482        match msg {
1483            HyperliquidWsMessage::User { data } => match data {
1484                WsUserEventData::Fills { fills } => {
1485                    assert_eq!(fills.len(), 1);
1486                    let fill = &fills[0];
1487                    assert_eq!(fill.coin, "BTC");
1488                    assert_eq!(fill.px, dec!(79146.0));
1489                    assert_eq!(fill.side, HyperliquidSide::Sell);
1490                    assert_eq!(fill.builder_fee, Some(dec!(0.007914)));
1491                    assert_eq!(fill.fee_token, "USDC");
1492                }
1493                _ => panic!("Expected Fills variant"),
1494            },
1495            _ => panic!("Expected User channel message"),
1496        }
1497    }
1498
1499    #[rstest]
1500    fn test_ws_user_fills_message_with_liquidation() {
1501        // Real message from production that failed to parse: the liquidation
1502        // block carries `markPx` as a quoted string like every other decimal.
1503        let json = include_str!("../../test_data/ws_user_fill_liquidation.json");
1504
1505        let msg: HyperliquidWsMessage = serde_json::from_str(json).unwrap();
1506
1507        match msg {
1508            HyperliquidWsMessage::User { data } => match data {
1509                WsUserEventData::Fills { fills } => {
1510                    assert_eq!(fills.len(), 1);
1511                    let fill = &fills[0];
1512                    let liquidation = fill.liquidation.as_ref().expect("expected liquidation");
1513                    assert_eq!(fill.coin, "BTC");
1514                    assert_eq!(fill.side, HyperliquidSide::Sell);
1515                    assert_eq!(liquidation.mark_px, dec!(66607.0));
1516                    assert_eq!(liquidation.method, HyperliquidLiquidationMethod::Market);
1517                    assert_eq!(
1518                        liquidation.liquidated_user.as_deref(),
1519                        Some("0x360878d351f05975e25f1807a27895e1e5e004fb"),
1520                    );
1521                }
1522                _ => panic!("Expected Fills variant"),
1523            },
1524            _ => panic!("Expected User channel message"),
1525        }
1526    }
1527
1528    #[rstest]
1529    fn test_ws_trade_data_round_trips_decimals_as_strings() {
1530        // Deserializing into Decimal then serializing must reproduce the
1531        // string wire form (with scale preserved), not emit a JSON number.
1532        let json = r#"{"coin":"BTC","side":"B","px":"66653.0","sz":"0.001","hash":"0xabc","time":1,"tid":2,"users":["0xa","0xb"]}"#;
1533
1534        let trade: WsTradeData = serde_json::from_str(json).unwrap();
1535        assert_eq!(trade.px, dec!(66653.0));
1536        assert_eq!(trade.sz, dec!(0.001));
1537
1538        let value = serde_json::to_value(&trade).unwrap();
1539        assert_eq!(value["px"], serde_json::Value::from("66653.0"));
1540        assert_eq!(value["sz"], serde_json::Value::from("0.001"));
1541    }
1542}
1543
1544/// Nautilus WebSocket message wrapper for routing to execution engine.
1545///
1546/// Wraps parsed messages from the handler.
1547///
1548/// All parsing happens in the handler layer, with parsed Nautilus domain objects.
1549/// passed through to the Python layer.
1550#[derive(Debug, Clone)]
1551pub enum NautilusWsMessage {
1552    /// Execution reports (order status and fills).
1553    ExecutionReports(Vec<ExecutionReport>),
1554    /// Parsed trade ticks.
1555    Trades(Vec<TradeTick>),
1556    /// Parsed quote tick (from BBO).
1557    Quote(QuoteTick),
1558    /// Parsed order book deltas.
1559    Deltas(OrderBookDeltas),
1560    /// Parsed order book depth-10 snapshot.
1561    Depth(Box<OrderBookDepth>),
1562    /// An order book frame that failed to parse, leaving the instrument's book out of sync.
1563    BookInvalid(InstrumentId),
1564    /// Parsed candle/bar.
1565    Candle(Bar),
1566    /// Mark price update.
1567    MarkPrice(MarkPriceUpdate),
1568    /// Index price update.
1569    IndexPrice(IndexPriceUpdate),
1570    /// Funding rate update.
1571    FundingRate(FundingRateUpdate),
1572    /// Custom data (e.g. allMids).
1573    CustomData(Data),
1574    /// Error occurred.
1575    Error(String),
1576    /// WebSocket reconnected.
1577    Reconnected,
1578}
1579
1580/// Execution report wrapper for order status and fill reports.
1581///
1582/// This enum allows both order status updates and fill reports.
1583/// to be sent through the execution engine.
1584#[derive(Debug, Clone)]
1585#[allow(
1586    clippy::large_enum_variant,
1587    reason = "the variant size gap only crosses the threshold when high-precision widens the raw types"
1588)]
1589pub enum ExecutionReport {
1590    /// Order status report.
1591    Order(OrderStatusReport),
1592    /// Fill report.
1593    Fill(FillReport),
1594}