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 ahash::AHashMap;
17use derive_builder::Builder;
18use nautilus_core::serialization::{
19    deserialize_decimal, deserialize_decimal_from_str, deserialize_optional_decimal_from_str,
20    serialize_decimal_as_str,
21};
22use nautilus_model::{
23    data::{
24        Bar, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate, OrderBookDeltas,
25        OrderBookDepth10, QuoteTick, TradeTick,
26    },
27    reports::{FillReport, OrderStatusReport},
28};
29use rust_decimal::Decimal;
30use serde::{Deserialize, Serialize};
31use ustr::Ustr;
32
33use crate::{
34    common::enums::{
35        HyperliquidBarInterval, HyperliquidFillDirection, HyperliquidLiquidationMethod,
36        HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide,
37        HyperliquidTimeInForce, HyperliquidTpSl, HyperliquidTwapStatus,
38    },
39    http::models::{HyperliquidExchangeAction, HyperliquidExchangeRequest},
40};
41
42/// Represents an outbound WebSocket message from client to Hyperliquid.
43#[derive(Debug, Clone, Serialize)]
44#[serde(tag = "method")]
45#[serde(rename_all = "lowercase")]
46pub enum HyperliquidWsRequest {
47    /// Subscribe to a data feed.
48    Subscribe {
49        /// Subscription details.
50        subscription: SubscriptionRequest,
51    },
52    /// Unsubscribe from a data feed.
53    Unsubscribe {
54        /// Subscription details to remove.
55        subscription: SubscriptionRequest,
56    },
57    /// Post a request (info or action).
58    Post {
59        /// Request ID for tracking.
60        id: u64,
61        /// Request payload.
62        request: PostRequest,
63    },
64    /// Ping for keepalive.
65    Ping,
66}
67
68/// Represents subscription request types for WebSocket feeds.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70#[serde(tag = "type")]
71#[serde(rename_all = "camelCase")]
72pub enum SubscriptionRequest {
73    /// All mid prices across markets.
74    AllMids {
75        #[serde(skip_serializing_if = "Option::is_none")]
76        dex: Option<String>,
77    },
78    /// Aggregate asset contexts across all perp dexes.
79    AllDexsAssetCtxs,
80    /// Notifications for a user.
81    Notification { user: String },
82    /// Web data for frontend.
83    WebData2 { user: String },
84    /// Candlestick data.
85    Candle {
86        coin: Ustr,
87        interval: HyperliquidBarInterval,
88    },
89    /// Level 2 order book.
90    L2Book {
91        coin: Ustr,
92        #[serde(skip_serializing_if = "Option::is_none")]
93        #[serde(rename = "nSigFigs")]
94        n_sig_figs: Option<u32>,
95        #[serde(skip_serializing_if = "Option::is_none")]
96        mantissa: Option<u32>,
97    },
98    /// Trade updates.
99    Trades { coin: Ustr },
100    /// Order updates for a user.
101    OrderUpdates { user: String },
102    /// User events (fills, funding, liquidations).
103    UserEvents { user: String },
104    /// User fill history.
105    UserFills {
106        user: String,
107        #[serde(skip_serializing_if = "Option::is_none")]
108        #[serde(rename = "aggregateByTime")]
109        aggregate_by_time: Option<bool>,
110    },
111    /// User funding payments.
112    UserFundings { user: String },
113    /// User ledger updates (non-funding).
114    UserNonFundingLedgerUpdates { user: String },
115    /// Active asset context (for perpetuals).
116    ActiveAssetCtx { coin: Ustr },
117    /// Active spot asset context.
118    ActiveSpotAssetCtx { coin: Ustr },
119    /// Active asset data for user.
120    ActiveAssetData { user: String, coin: String },
121    /// TWAP slice fills.
122    UserTwapSliceFills { user: String },
123    /// TWAP history.
124    UserTwapHistory { user: String },
125    /// Best bid/offer updates.
126    Bbo { coin: Ustr },
127}
128
129/// Post request wrapper for info and action requests.
130#[derive(Debug, Clone, Serialize)]
131#[serde(tag = "type")]
132#[serde(rename_all = "lowercase")]
133pub enum PostRequest {
134    /// Info request (no signature required).
135    Info { payload: serde_json::Value },
136    /// Action request (requires signature).
137    Action {
138        payload: HyperliquidExchangeRequest<HyperliquidExchangeAction>,
139    },
140}
141
142/// Action payload with signature.
143#[derive(Debug, Clone, Serialize)]
144pub struct ActionPayload {
145    pub action: ActionRequest,
146    pub nonce: u64,
147    pub signature: SignatureData,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    #[serde(rename = "vaultAddress")]
150    pub vault_address: Option<String>,
151}
152
153/// Signature data.
154#[derive(Debug, Clone, Serialize)]
155pub struct SignatureData {
156    pub r: String,
157    pub s: String,
158    pub v: String,
159}
160
161/// Action request types.
162#[derive(Debug, Clone, Serialize)]
163#[serde(tag = "type")]
164#[serde(rename_all = "lowercase")]
165pub enum ActionRequest {
166    /// Place orders.
167    Order {
168        orders: Vec<OrderRequest>,
169        grouping: String,
170    },
171    /// Cancel orders.
172    Cancel {
173        cancels: Vec<CancelRequest>,
174        #[serde(rename = "f", skip_serializing_if = "Option::is_none")]
175        fast: Option<bool>,
176    },
177    /// Cancel orders by client order ID.
178    CancelByCloid {
179        cancels: Vec<CancelByCloidRequest>,
180        #[serde(rename = "f", skip_serializing_if = "Option::is_none")]
181        fast: Option<bool>,
182    },
183    /// Modify orders.
184    Modify { modifies: Vec<ModifyRequest> },
185}
186
187impl ActionRequest {
188    /// Create a simple order action with default "na" grouping
189    ///
190    /// # Example
191    /// ```ignore
192    /// let action = ActionRequest::order(vec![order1, order2], "na");
193    /// ```
194    pub fn order(orders: Vec<OrderRequest>, grouping: impl Into<String>) -> Self {
195        Self::Order {
196            orders,
197            grouping: grouping.into(),
198        }
199    }
200
201    /// Create a cancel action for multiple orders
202    ///
203    /// # Example
204    /// ```ignore
205    /// let action = ActionRequest::cancel(vec![
206    ///     CancelRequest { a: 0, o: 12345 },
207    ///     CancelRequest { a: 1, o: 67890 },
208    /// ]);
209    /// ```
210    pub fn cancel(cancels: Vec<CancelRequest>) -> Self {
211        Self::Cancel {
212            cancels,
213            fast: None,
214        }
215    }
216
217    /// Create a cancel-by-cloid action
218    ///
219    /// # Example
220    /// ```ignore
221    /// let action = ActionRequest::cancel_by_cloid(vec![
222    ///     CancelByCloidRequest { asset: 0, cloid: "order-1".to_string() },
223    /// ]);
224    /// ```
225    pub fn cancel_by_cloid(cancels: Vec<CancelByCloidRequest>) -> Self {
226        Self::CancelByCloid {
227            cancels,
228            fast: None,
229        }
230    }
231
232    /// Create a modify action for multiple orders
233    ///
234    /// # Example
235    /// ```ignore
236    /// let action = ActionRequest::modify(vec![
237    ///     ModifyRequest { oid: 12345, order: new_order },
238    /// ]);
239    /// ```
240    pub fn modify(modifies: Vec<ModifyRequest>) -> Self {
241        Self::Modify { modifies }
242    }
243}
244
245/// Order placement request.
246#[derive(Debug, Clone, Serialize, Builder)]
247pub struct OrderRequest {
248    /// Asset ID.
249    pub a: u32,
250    /// Buy side (true = buy, false = sell).
251    pub b: bool,
252    /// Price.
253    pub p: String,
254    /// Size.
255    pub s: String,
256    /// Reduce only.
257    pub r: bool,
258    /// Order type.
259    pub t: OrderTypeRequest,
260    /// Client order ID (optional).
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub c: Option<String>,
263}
264
265/// Order type in request format.
266#[derive(Debug, Clone, Serialize)]
267#[serde(tag = "type")]
268#[serde(rename_all = "lowercase")]
269pub enum OrderTypeRequest {
270    Limit {
271        tif: TimeInForceRequest,
272    },
273    Trigger {
274        #[serde(rename = "isMarket")]
275        is_market: bool,
276        #[serde(rename = "triggerPx")]
277        trigger_px: String,
278        tpsl: TpSlRequest,
279    },
280}
281
282/// Time in force in request format.
283#[derive(Debug, Clone, Serialize)]
284#[serde(rename_all = "PascalCase")]
285pub enum TimeInForceRequest {
286    Alo,
287    Ioc,
288    Gtc,
289}
290
291/// TP/SL in request format.
292#[derive(Debug, Clone, Serialize)]
293#[serde(rename_all = "lowercase")]
294pub enum TpSlRequest {
295    Tp,
296    Sl,
297}
298
299/// Cancel order request.
300#[derive(Debug, Clone, Serialize)]
301pub struct CancelRequest {
302    /// Asset ID.
303    pub a: u32,
304    /// Order ID.
305    pub o: u64,
306}
307
308/// Cancel by client order ID request.
309#[derive(Debug, Clone, Serialize)]
310pub struct CancelByCloidRequest {
311    /// Asset ID.
312    pub asset: u32,
313    /// Client order ID.
314    pub cloid: String,
315}
316
317/// Modify order request.
318#[derive(Debug, Clone, Serialize)]
319pub struct ModifyRequest {
320    /// Order ID.
321    pub oid: u64,
322    /// New order details.
323    pub order: OrderRequest,
324}
325
326/// Subscription response data wrapper.
327#[derive(Debug, Clone, Deserialize)]
328pub struct SubscriptionResponseData {
329    pub method: String,
330    pub subscription: SubscriptionRequest,
331}
332
333/// Inbound WebSocket message from Hyperliquid server.
334#[derive(Debug, Clone, Deserialize)]
335#[serde(tag = "channel")]
336#[serde(rename_all = "camelCase")]
337pub enum HyperliquidWsMessage {
338    /// Subscription confirmation.
339    SubscriptionResponse { data: SubscriptionResponseData },
340    /// Post request response.
341    Post { data: PostResponse },
342    /// All mid prices.
343    AllMids { data: AllMidsData },
344    /// Aggregate asset contexts across all perp dexes.
345    AllDexsAssetCtxs { data: WsAllDexsAssetCtxsData },
346    /// Notifications.
347    Notification { data: NotificationData },
348    /// Web data.
349    WebData2 { data: serde_json::Value },
350    /// Candlestick data.
351    Candle { data: CandleData },
352    /// Level 2 order book.
353    L2Book { data: WsBookData },
354    /// Trade updates.
355    Trades { data: Vec<WsTradeData> },
356    /// Order updates.
357    OrderUpdates { data: Vec<WsOrderData> },
358    /// User events.
359    UserEvents { data: WsUserEventData },
360    /// Generic user channel (Hyperliquid sends fills/events on this channel).
361    #[serde(rename = "user")]
362    User { data: WsUserEventData },
363    /// User fills.
364    UserFills { data: WsUserFillsData },
365    /// User funding payments.
366    UserFundings { data: WsUserFundingsData },
367    /// User ledger updates.
368    UserNonFundingLedgerUpdates { data: serde_json::Value },
369    /// Active asset context.
370    ActiveAssetCtx { data: WsActiveAssetCtxData },
371    /// Active spot asset context (same data as ActiveAssetCtx, different channel name).
372    ActiveSpotAssetCtx { data: WsActiveAssetCtxData },
373    /// Active asset data.
374    ActiveAssetData { data: WsActiveAssetData },
375    /// TWAP slice fills.
376    UserTwapSliceFills { data: WsUserTwapSliceFillsData },
377    /// TWAP history.
378    UserTwapHistory { data: WsUserTwapHistoryData },
379    /// Best bid/offer.
380    Bbo { data: WsBboData },
381    /// Error response.
382    Error { data: String },
383    /// Pong response.
384    Pong,
385}
386
387/// Post response data.
388#[derive(Debug, Clone, Deserialize)]
389pub struct PostResponse {
390    pub id: u64,
391    pub response: PostResponsePayload,
392}
393
394/// Post response payload.
395#[derive(Debug, Clone, Deserialize)]
396#[serde(tag = "type")]
397#[serde(rename_all = "lowercase")]
398pub enum PostResponsePayload {
399    Info { payload: serde_json::Value },
400    Action { payload: serde_json::Value },
401    Error { payload: String },
402}
403
404/// All mid prices data.
405#[derive(Debug, Clone, Deserialize)]
406pub struct AllMidsData {
407    pub mids: AHashMap<Ustr, String>,
408}
409
410/// `allDexsAssetCtxs` data payload.
411#[derive(Debug, Clone, Deserialize)]
412pub struct WsAllDexsAssetCtxsData {
413    pub ctxs: Vec<(String, Vec<PerpsAssetCtx>)>,
414}
415
416/// Notification data.
417#[derive(Debug, Clone, Deserialize)]
418pub struct NotificationData {
419    pub notification: String,
420}
421
422/// Candlestick data.
423#[derive(Debug, Clone, Deserialize)]
424pub struct CandleData {
425    /// Open time (millis).
426    pub t: u64,
427    /// Close time (millis).
428    #[serde(rename = "T")]
429    pub close_time: u64,
430    /// Symbol.
431    pub s: Ustr,
432    /// Interval.
433    pub i: Ustr,
434    /// Open price.
435    #[serde(deserialize_with = "deserialize_decimal_from_str")]
436    pub o: Decimal,
437    /// Close price.
438    #[serde(deserialize_with = "deserialize_decimal_from_str")]
439    pub c: Decimal,
440    /// High price.
441    #[serde(deserialize_with = "deserialize_decimal_from_str")]
442    pub h: Decimal,
443    /// Low price.
444    #[serde(deserialize_with = "deserialize_decimal_from_str")]
445    pub l: Decimal,
446    /// Volume.
447    #[serde(deserialize_with = "deserialize_decimal_from_str")]
448    pub v: Decimal,
449    /// Number of trades.
450    pub n: u32,
451}
452
453/// WebSocket book data.
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct WsBookData {
456    pub coin: Ustr,
457    pub levels: [Vec<WsLevelData>; 2], // [bids, asks]
458    pub time: u64,
459}
460
461/// WebSocket level data.
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct WsLevelData {
464    /// Price.
465    #[serde(
466        deserialize_with = "deserialize_decimal_from_str",
467        serialize_with = "serialize_decimal_as_str"
468    )]
469    pub px: Decimal,
470    /// Size.
471    #[serde(
472        deserialize_with = "deserialize_decimal_from_str",
473        serialize_with = "serialize_decimal_as_str"
474    )]
475    pub sz: Decimal,
476    /// Number of orders.
477    pub n: u32,
478}
479
480/// WebSocket trade data.
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct WsTradeData {
483    pub coin: Ustr,
484    pub side: HyperliquidSide,
485    #[serde(
486        deserialize_with = "deserialize_decimal_from_str",
487        serialize_with = "serialize_decimal_as_str"
488    )]
489    pub px: Decimal,
490    #[serde(
491        deserialize_with = "deserialize_decimal_from_str",
492        serialize_with = "serialize_decimal_as_str"
493    )]
494    pub sz: Decimal,
495    pub hash: String,
496    pub time: u64,
497    pub tid: u64,
498    pub users: [String; 2], // [buyer, seller]
499}
500
501/// WebSocket order data.
502#[derive(Debug, Clone, Deserialize)]
503pub struct WsOrderData {
504    pub order: WsBasicOrderData,
505    pub status: HyperliquidOrderStatusEnum,
506    #[serde(rename = "statusTimestamp")]
507    pub status_timestamp: u64,
508}
509
510/// Basic order data.
511#[derive(Debug, Clone, Deserialize)]
512pub struct WsBasicOrderData {
513    pub coin: Ustr,
514    pub side: HyperliquidSide,
515    #[serde(rename = "limitPx", deserialize_with = "deserialize_decimal_from_str")]
516    pub limit_px: Decimal,
517    #[serde(deserialize_with = "deserialize_decimal_from_str")]
518    pub sz: Decimal,
519    pub oid: u64,
520    pub timestamp: u64,
521    #[serde(rename = "origSz", deserialize_with = "deserialize_decimal_from_str")]
522    pub orig_sz: Decimal,
523    pub cloid: Option<String>,
524    pub tif: Option<HyperliquidTimeInForce>,
525    #[serde(rename = "reduceOnly")]
526    pub reduce_only: Option<bool>,
527    /// Trigger price for conditional orders (stop/take-profit).
528    #[serde(
529        rename = "triggerPx",
530        default,
531        deserialize_with = "deserialize_optional_decimal_from_str"
532    )]
533    pub trigger_px: Option<Decimal>,
534    /// Whether this is a market or limit trigger order.
535    #[serde(rename = "isMarket")]
536    pub is_market: Option<bool>,
537    /// Take-profit or stop-loss indicator.
538    pub tpsl: Option<HyperliquidTpSl>,
539    /// Whether the trigger has been activated.
540    #[serde(rename = "triggerActivated")]
541    pub trigger_activated: Option<bool>,
542    /// Trailing stop parameters if applicable.
543    #[serde(rename = "trailingStop")]
544    pub trailing_stop: Option<WsTrailingStopData>,
545}
546
547/// Trailing stop offset type.
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
549#[serde(rename_all = "camelCase")]
550pub enum TrailingOffsetType {
551    /// Price offset.
552    Price,
553    /// Percentage offset.
554    Percentage,
555    /// Basis points offset.
556    BasisPoints,
557}
558
559impl TrailingOffsetType {
560    /// Format the offset value with the appropriate unit.
561    pub fn format_offset(&self, offset: &str) -> String {
562        match self {
563            Self::Price => offset.to_string(),
564            Self::Percentage => format!("{offset}%"),
565            Self::BasisPoints => format!("{offset} bps"),
566        }
567    }
568}
569
570/// Trailing stop data from WebSocket.
571#[derive(Debug, Clone, Deserialize)]
572pub struct WsTrailingStopData {
573    /// Trailing offset value.
574    #[serde(deserialize_with = "deserialize_decimal_from_str")]
575    pub offset: Decimal,
576    /// Offset type.
577    #[serde(rename = "offsetType")]
578    pub offset_type: TrailingOffsetType,
579    /// Current callback price (highest/lowest price reached).
580    #[serde(
581        rename = "callbackPrice",
582        default,
583        deserialize_with = "deserialize_optional_decimal_from_str"
584    )]
585    pub callback_price: Option<Decimal>,
586}
587
588/// WebSocket user event data.
589#[derive(Debug, Clone, Deserialize)]
590#[serde(untagged)]
591pub enum WsUserEventData {
592    Fills {
593        fills: Vec<WsFillData>,
594    },
595    Funding {
596        funding: WsUserFundingData,
597    },
598    Liquidation {
599        liquidation: WsLiquidationData,
600    },
601    NonUserCancel {
602        #[serde(rename = "nonUserCancel")]
603        non_user_cancel: Vec<WsNonUserCancelData>,
604    },
605    /// Trigger order activated (moved from pending to active).
606    TriggerActivated {
607        #[serde(rename = "triggerActivated")]
608        trigger_activated: WsTriggerActivatedData,
609    },
610    /// Trigger order executed (trigger price reached, order placed).
611    TriggerTriggered {
612        #[serde(rename = "triggerTriggered")]
613        trigger_triggered: WsTriggerTriggeredData,
614    },
615}
616
617/// WebSocket fill data.
618#[derive(Debug, Clone, Deserialize)]
619pub struct WsFillData {
620    pub coin: Ustr,
621    #[serde(deserialize_with = "deserialize_decimal_from_str")]
622    pub px: Decimal,
623    #[serde(deserialize_with = "deserialize_decimal_from_str")]
624    pub sz: Decimal,
625    pub side: HyperliquidSide,
626    pub time: u64,
627    #[serde(
628        rename = "startPosition",
629        deserialize_with = "deserialize_decimal_from_str"
630    )]
631    pub start_position: Decimal,
632    pub dir: HyperliquidFillDirection,
633    #[serde(
634        rename = "closedPnl",
635        deserialize_with = "deserialize_decimal_from_str"
636    )]
637    pub closed_pnl: Decimal,
638    pub hash: String,
639    pub oid: u64,
640    pub crossed: bool,
641    #[serde(deserialize_with = "deserialize_decimal_from_str")]
642    pub fee: Decimal,
643    pub tid: u64,
644    #[serde(default)]
645    pub liquidation: Option<FillLiquidationData>,
646    #[serde(rename = "feeToken")]
647    pub fee_token: Ustr,
648    #[serde(
649        rename = "builderFee",
650        default,
651        deserialize_with = "deserialize_optional_decimal_from_str"
652    )]
653    pub builder_fee: Option<Decimal>,
654    /// Client order ID (hex string with 0x prefix).
655    pub cloid: Option<String>,
656    /// TWAP order ID if this fill is part of a TWAP order.
657    #[serde(rename = "twapId")]
658    pub twap_id: Option<serde_json::Value>,
659}
660
661/// Fill liquidation data.
662#[derive(Debug, Clone, Deserialize)]
663pub struct FillLiquidationData {
664    #[serde(rename = "liquidatedUser")]
665    pub liquidated_user: Option<String>,
666    #[serde(rename = "markPx", deserialize_with = "deserialize_decimal_from_str")]
667    pub mark_px: Decimal,
668    pub method: HyperliquidLiquidationMethod,
669}
670
671/// WebSocket user funding data.
672#[derive(Debug, Clone, Deserialize)]
673pub struct WsUserFundingData {
674    pub time: u64,
675    pub coin: Ustr,
676    #[serde(deserialize_with = "deserialize_decimal_from_str")]
677    pub usdc: Decimal,
678    #[serde(deserialize_with = "deserialize_decimal_from_str")]
679    pub szi: Decimal,
680    #[serde(
681        rename = "fundingRate",
682        deserialize_with = "deserialize_decimal_from_str"
683    )]
684    pub funding_rate: Decimal,
685}
686
687/// WebSocket liquidation data.
688#[derive(Debug, Clone, Deserialize)]
689pub struct WsLiquidationData {
690    pub lid: u64,
691    pub liquidator: String,
692    pub liquidated_user: String,
693    #[serde(deserialize_with = "deserialize_decimal_from_str")]
694    pub liquidated_ntl_pos: Decimal,
695    #[serde(deserialize_with = "deserialize_decimal_from_str")]
696    pub liquidated_account_value: Decimal,
697}
698
699/// WebSocket non-user cancel data.
700#[derive(Debug, Clone, Deserialize)]
701pub struct WsNonUserCancelData {
702    pub coin: Ustr,
703    pub oid: u64,
704}
705
706/// Trigger order activated event data.
707#[derive(Debug, Clone, Deserialize)]
708pub struct WsTriggerActivatedData {
709    pub coin: Ustr,
710    pub oid: u64,
711    pub time: u64,
712    #[serde(
713        rename = "triggerPx",
714        deserialize_with = "deserialize_decimal_from_str"
715    )]
716    pub trigger_px: Decimal,
717    pub tpsl: HyperliquidTpSl,
718}
719
720/// Trigger order triggered event data.
721#[derive(Debug, Clone, Deserialize)]
722pub struct WsTriggerTriggeredData {
723    pub coin: Ustr,
724    pub oid: u64,
725    pub time: u64,
726    #[serde(
727        rename = "triggerPx",
728        deserialize_with = "deserialize_decimal_from_str"
729    )]
730    pub trigger_px: Decimal,
731    #[serde(rename = "marketPx", deserialize_with = "deserialize_decimal_from_str")]
732    pub market_px: Decimal,
733    pub tpsl: HyperliquidTpSl,
734    /// Order ID of the resulting market/limit order after trigger.
735    #[serde(rename = "resultingOid")]
736    pub resulting_oid: Option<u64>,
737}
738
739/// WebSocket user fills data.
740#[derive(Debug, Clone, Deserialize)]
741pub struct WsUserFillsData {
742    #[serde(rename = "isSnapshot")]
743    pub is_snapshot: Option<bool>,
744    pub user: String,
745    pub fills: Vec<WsFillData>,
746}
747
748/// WebSocket user fundings data.
749#[derive(Debug, Clone, Deserialize)]
750pub struct WsUserFundingsData {
751    #[serde(rename = "isSnapshot")]
752    pub is_snapshot: Option<bool>,
753    pub user: String,
754    pub fundings: Vec<WsUserFundingData>,
755}
756
757/// WebSocket active asset context data.
758#[derive(Debug, Clone, Deserialize)]
759#[serde(untagged)]
760pub enum WsActiveAssetCtxData {
761    Perp { coin: Ustr, ctx: PerpsAssetCtx },
762    Spot { coin: Ustr, ctx: SpotAssetCtx },
763}
764
765/// Shared asset context fields.
766#[derive(Debug, Clone, Deserialize)]
767pub struct SharedAssetCtx {
768    #[serde(
769        rename = "dayNtlVlm",
770        deserialize_with = "deserialize_decimal_from_str"
771    )]
772    pub day_ntl_vlm: Decimal,
773    #[serde(
774        rename = "prevDayPx",
775        deserialize_with = "deserialize_decimal_from_str"
776    )]
777    pub prev_day_px: Decimal,
778    #[serde(rename = "markPx", deserialize_with = "deserialize_decimal_from_str")]
779    pub mark_px: Decimal,
780    #[serde(
781        rename = "midPx",
782        default,
783        deserialize_with = "deserialize_optional_decimal_from_str"
784    )]
785    pub mid_px: Option<Decimal>,
786    #[serde(rename = "impactPxs")]
787    pub impact_pxs: Option<Vec<String>>,
788    #[serde(
789        rename = "dayBaseVlm",
790        default,
791        deserialize_with = "deserialize_optional_decimal_from_str"
792    )]
793    pub day_base_vlm: Option<Decimal>,
794}
795
796/// Perps asset context.
797#[derive(Debug, Clone, Deserialize)]
798pub struct PerpsAssetCtx {
799    #[serde(flatten)]
800    pub shared: SharedAssetCtx,
801    #[serde(deserialize_with = "deserialize_decimal_from_str")]
802    pub funding: Decimal,
803    #[serde(
804        rename = "openInterest",
805        deserialize_with = "deserialize_decimal_from_str"
806    )]
807    pub open_interest: Decimal,
808    #[serde(rename = "oraclePx", deserialize_with = "deserialize_decimal_from_str")]
809    pub oracle_px: Decimal,
810    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
811    pub premium: Option<Decimal>,
812}
813
814/// Spot asset context.
815#[derive(Debug, Clone, Deserialize)]
816pub struct SpotAssetCtx {
817    #[serde(flatten)]
818    pub shared: SharedAssetCtx,
819    #[serde(
820        rename = "circulatingSupply",
821        deserialize_with = "deserialize_decimal_from_str"
822    )]
823    pub circulating_supply: Decimal,
824}
825
826/// WebSocket active asset data.
827#[derive(Debug, Clone, Deserialize)]
828pub struct WsActiveAssetData {
829    pub user: String,
830    pub coin: Ustr,
831    pub leverage: LeverageData,
832    #[serde(rename = "maxTradeSzs")]
833    pub max_trade_szs: [f64; 2],
834    #[serde(rename = "availableToTrade")]
835    pub available_to_trade: [f64; 2],
836}
837
838/// Leverage data.
839#[derive(Debug, Clone, Deserialize)]
840pub struct LeverageData {
841    pub value: f64,
842    pub type_: String,
843}
844
845/// WebSocket TWAP slice fills data.
846#[derive(Debug, Clone, Deserialize)]
847pub struct WsUserTwapSliceFillsData {
848    #[serde(rename = "isSnapshot")]
849    pub is_snapshot: Option<bool>,
850    pub user: String,
851    #[serde(rename = "twapSliceFills")]
852    pub twap_slice_fills: Vec<WsTwapSliceFillData>,
853}
854
855/// TWAP slice fill data.
856#[derive(Debug, Clone, Deserialize)]
857pub struct WsTwapSliceFillData {
858    pub fill: WsFillData,
859    #[serde(rename = "twapId")]
860    pub twap_id: u64,
861}
862
863/// WebSocket TWAP history data.
864#[derive(Debug, Clone, Deserialize)]
865pub struct WsUserTwapHistoryData {
866    #[serde(rename = "isSnapshot")]
867    pub is_snapshot: Option<bool>,
868    pub user: String,
869    pub history: Vec<WsTwapHistoryData>,
870}
871
872/// TWAP history data.
873#[derive(Debug, Clone, Deserialize)]
874pub struct WsTwapHistoryData {
875    pub state: TwapStateData,
876    pub status: TwapStatusData,
877    pub time: u64,
878    #[serde(default, rename = "twapId")]
879    pub twap_id: Option<u64>,
880}
881
882/// TWAP state data.
883#[derive(Debug, Clone, Deserialize)]
884pub struct TwapStateData {
885    pub coin: Ustr,
886    pub user: String,
887    pub side: HyperliquidSide,
888    /// Venue may send a JSON string or number.
889    #[serde(deserialize_with = "deserialize_decimal")]
890    pub sz: Decimal,
891    #[serde(rename = "executedSz", deserialize_with = "deserialize_decimal")]
892    pub executed_sz: Decimal,
893    #[serde(rename = "executedNtl", deserialize_with = "deserialize_decimal")]
894    pub executed_ntl: Decimal,
895    pub minutes: u32,
896    #[serde(rename = "reduceOnly")]
897    pub reduce_only: bool,
898    pub randomize: bool,
899    pub timestamp: u64,
900}
901
902/// TWAP status data.
903#[derive(Debug, Clone, Deserialize)]
904pub struct TwapStatusData {
905    pub status: HyperliquidTwapStatus,
906    /// Present when `status` is `error`; otherwise often omitted.
907    #[serde(default)]
908    pub description: String,
909}
910
911/// WebSocket BBO data.
912#[derive(Debug, Clone, Deserialize)]
913pub struct WsBboData {
914    pub coin: Ustr,
915    pub time: u64,
916    pub bbo: [Option<WsLevelData>; 2], // [bid, ask]
917}
918
919#[cfg(test)]
920mod tests {
921    use rstest::rstest;
922    use rust_decimal_macros::dec;
923    use serde_json;
924
925    use super::*;
926
927    #[rstest]
928    fn test_subscription_request_serialization() {
929        let sub = SubscriptionRequest::L2Book {
930            coin: Ustr::from("BTC"),
931            n_sig_figs: Some(5),
932            mantissa: None,
933        };
934
935        let json = serde_json::to_string(&sub).unwrap();
936        assert!(json.contains(r#""type":"l2Book""#));
937        assert!(json.contains(r#""coin":"BTC""#));
938    }
939
940    #[rstest]
941    fn test_hyperliquid_ws_request_serialization() {
942        let req = HyperliquidWsRequest::Subscribe {
943            subscription: SubscriptionRequest::Trades {
944                coin: Ustr::from("ETH"),
945            },
946        };
947
948        let json = serde_json::to_string(&req).unwrap();
949        assert!(json.contains(r#""method":"subscribe""#));
950        assert!(json.contains(r#""type":"trades""#));
951    }
952
953    #[rstest]
954    fn test_order_request_serialization() {
955        let order = OrderRequest {
956            a: 0,    // BTC asset ID
957            b: true, // buy
958            p: "50000.0".to_string(),
959            s: "0.1".to_string(),
960            r: false,
961            t: OrderTypeRequest::Limit {
962                tif: TimeInForceRequest::Gtc,
963            },
964            c: Some("client-123".to_string()),
965        };
966
967        let json = serde_json::to_string(&order).unwrap();
968        assert!(json.contains(r#""a":0"#));
969        assert!(json.contains(r#""b":true"#));
970        assert!(json.contains(r#""p":"50000.0""#));
971    }
972
973    #[rstest]
974    fn test_ws_trade_data_deserialization() {
975        let json = r#"{
976            "coin": "BTC",
977            "side": "B",
978            "px": "50000.0",
979            "sz": "0.1",
980            "hash": "0x123",
981            "time": 1234567890,
982            "tid": 12345,
983            "users": ["0xabc", "0xdef"]
984        }"#;
985
986        let trade: WsTradeData = serde_json::from_str(json).unwrap();
987        assert_eq!(trade.coin, "BTC");
988        assert_eq!(trade.side, HyperliquidSide::Buy);
989        assert_eq!(trade.px, dec!(50000.0));
990    }
991
992    #[rstest]
993    fn test_ws_book_data_deserialization() {
994        let json = r#"{
995            "coin": "ETH",
996            "levels": [
997                [{"px": "3000.0", "sz": "1.0", "n": 1}],
998                [{"px": "3001.0", "sz": "2.0", "n": 2}]
999            ],
1000            "time": 1234567890
1001        }"#;
1002
1003        let book: WsBookData = serde_json::from_str(json).unwrap();
1004        assert_eq!(book.coin, "ETH");
1005        assert_eq!(book.levels[0].len(), 1);
1006        assert_eq!(book.levels[1].len(), 1);
1007    }
1008
1009    #[rstest]
1010    fn test_ws_trailing_stop_data_deserialization() {
1011        let json = r#"{
1012            "offset": "100.0",
1013            "offsetType": "price",
1014            "callbackPrice": "50000.0"
1015        }"#;
1016
1017        let data: WsTrailingStopData = serde_json::from_str(json).unwrap();
1018        assert_eq!(data.offset, dec!(100.0));
1019        assert_eq!(data.offset_type, TrailingOffsetType::Price);
1020        assert_eq!(data.callback_price.unwrap(), dec!(50000.0));
1021    }
1022
1023    #[rstest]
1024    fn test_ws_trigger_activated_data_deserialization() {
1025        let json = r#"{
1026            "coin": "BTC",
1027            "oid": 12345,
1028            "time": 1704470400000,
1029            "triggerPx": "50000.0",
1030            "tpsl": "sl"
1031        }"#;
1032
1033        let data: WsTriggerActivatedData = serde_json::from_str(json).unwrap();
1034        assert_eq!(data.coin, Ustr::from("BTC"));
1035        assert_eq!(data.oid, 12345);
1036        assert_eq!(data.trigger_px, dec!(50000.0));
1037        assert_eq!(data.tpsl, HyperliquidTpSl::Sl);
1038        assert_eq!(data.time, 1704470400000);
1039    }
1040
1041    #[rstest]
1042    fn test_ws_trigger_triggered_data_deserialization() {
1043        let json = r#"{
1044            "coin": "ETH",
1045            "oid": 67890,
1046            "time": 1704470500000,
1047            "triggerPx": "3000.0",
1048            "marketPx": "3001.0",
1049            "tpsl": "tp",
1050            "resultingOid": 99999
1051        }"#;
1052
1053        let data: WsTriggerTriggeredData = serde_json::from_str(json).unwrap();
1054        assert_eq!(data.coin, Ustr::from("ETH"));
1055        assert_eq!(data.oid, 67890);
1056        assert_eq!(data.trigger_px, dec!(3000.0));
1057        assert_eq!(data.market_px, dec!(3001.0));
1058        assert_eq!(data.tpsl, HyperliquidTpSl::Tp);
1059        assert_eq!(data.resulting_oid, Some(99999));
1060    }
1061
1062    #[rstest]
1063    fn test_ws_fill_data_deserialization_with_cloid_and_twap() {
1064        let json = r#"{
1065            "coin": "@107",
1066            "px": "31.737",
1067            "sz": "0.31",
1068            "side": "B",
1069            "time": 1769920606068,
1070            "startPosition": "0.0",
1071            "dir": "Buy",
1072            "closedPnl": "0.0",
1073            "hash": "0xc731e7561e5334a0c8ab043472ce7d01d400ff3bb95653726afa92a8dd570e8b",
1074            "oid": 308086083674,
1075            "crossed": true,
1076            "fee": "0.00021699",
1077            "tid": 812806034449156,
1078            "cloid": "0xd211f1c27288259290850338d22132a0",
1079            "feeToken": "HYPE",
1080            "twapId": null
1081        }"#;
1082
1083        let fill: WsFillData = serde_json::from_str(json).unwrap();
1084        assert_eq!(fill.coin, "@107");
1085        assert_eq!(fill.px, dec!(31.737));
1086        assert_eq!(fill.sz, dec!(0.31));
1087        assert_eq!(fill.side, HyperliquidSide::Buy);
1088        assert_eq!(fill.oid, 308086083674);
1089        assert!(fill.crossed);
1090        assert_eq!(fill.fee, dec!(0.00021699));
1091        assert_eq!(fill.fee_token, "HYPE");
1092        assert_eq!(
1093            fill.cloid,
1094            Some("0xd211f1c27288259290850338d22132a0".to_string())
1095        );
1096        assert!(fill.twap_id.is_none() || fill.twap_id == Some(serde_json::Value::Null));
1097    }
1098
1099    #[rstest]
1100    fn test_ws_user_fills_message_deserialization() {
1101        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}]}}"#;
1102
1103        let msg: HyperliquidWsMessage = serde_json::from_str(json).unwrap();
1104
1105        match msg {
1106            HyperliquidWsMessage::User { data } => match data {
1107                WsUserEventData::Fills { fills } => {
1108                    assert_eq!(fills.len(), 1);
1109                    let fill = &fills[0];
1110                    assert_eq!(fill.coin, "@107");
1111                    assert_eq!(fill.px, dec!(31.737));
1112                    assert_eq!(
1113                        fill.cloid,
1114                        Some("0xd211f1c27288259290850338d22132a0".to_string())
1115                    );
1116                }
1117                _ => panic!("Expected Fills variant"),
1118            },
1119            _ => panic!("Expected User channel message"),
1120        }
1121    }
1122
1123    #[rstest]
1124    fn test_ws_user_fills_message_with_builder_fee() {
1125        // Real message from production that was failing
1126        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}]}}"#;
1127
1128        let msg: HyperliquidWsMessage = serde_json::from_str(json).unwrap();
1129
1130        match msg {
1131            HyperliquidWsMessage::User { data } => match data {
1132                WsUserEventData::Fills { fills } => {
1133                    assert_eq!(fills.len(), 1);
1134                    let fill = &fills[0];
1135                    assert_eq!(fill.coin, "BTC");
1136                    assert_eq!(fill.px, dec!(79146.0));
1137                    assert_eq!(fill.side, HyperliquidSide::Sell);
1138                    assert_eq!(fill.builder_fee, Some(dec!(0.007914)));
1139                    assert_eq!(fill.fee_token, "USDC");
1140                }
1141                _ => panic!("Expected Fills variant"),
1142            },
1143            _ => panic!("Expected User channel message"),
1144        }
1145    }
1146
1147    #[rstest]
1148    fn test_ws_user_fills_message_with_liquidation() {
1149        // Real message from production that failed to parse: the liquidation
1150        // block carries `markPx` as a quoted string like every other decimal.
1151        let json = include_str!("../../test_data/ws_user_fill_liquidation.json");
1152
1153        let msg: HyperliquidWsMessage = serde_json::from_str(json).unwrap();
1154
1155        match msg {
1156            HyperliquidWsMessage::User { data } => match data {
1157                WsUserEventData::Fills { fills } => {
1158                    assert_eq!(fills.len(), 1);
1159                    let fill = &fills[0];
1160                    let liquidation = fill.liquidation.as_ref().expect("expected liquidation");
1161                    assert_eq!(fill.coin, "BTC");
1162                    assert_eq!(fill.side, HyperliquidSide::Sell);
1163                    assert_eq!(liquidation.mark_px, dec!(66607.0));
1164                    assert_eq!(liquidation.method, HyperliquidLiquidationMethod::Market);
1165                    assert_eq!(
1166                        liquidation.liquidated_user.as_deref(),
1167                        Some("0x360878d351f05975e25f1807a27895e1e5e004fb"),
1168                    );
1169                }
1170                _ => panic!("Expected Fills variant"),
1171            },
1172            _ => panic!("Expected User channel message"),
1173        }
1174    }
1175
1176    #[rstest]
1177    fn test_ws_trade_data_round_trips_decimals_as_strings() {
1178        // Deserializing into Decimal then serializing must reproduce the
1179        // string wire form (with scale preserved), not emit a JSON number.
1180        let json = r#"{"coin":"BTC","side":"B","px":"66653.0","sz":"0.001","hash":"0xabc","time":1,"tid":2,"users":["0xa","0xb"]}"#;
1181
1182        let trade: WsTradeData = serde_json::from_str(json).unwrap();
1183        assert_eq!(trade.px, dec!(66653.0));
1184        assert_eq!(trade.sz, dec!(0.001));
1185
1186        let value = serde_json::to_value(&trade).unwrap();
1187        assert_eq!(value["px"], serde_json::Value::from("66653.0"));
1188        assert_eq!(value["sz"], serde_json::Value::from("0.001"));
1189    }
1190}
1191
1192/// Nautilus WebSocket message wrapper for routing to execution engine.
1193///
1194/// Wraps parsed messages from the handler.
1195///
1196/// All parsing happens in the handler layer, with parsed Nautilus domain objects.
1197/// passed through to the Python layer.
1198#[derive(Debug, Clone)]
1199pub enum NautilusWsMessage {
1200    /// Execution reports (order status and fills).
1201    ExecutionReports(Vec<ExecutionReport>),
1202    /// Parsed trade ticks.
1203    Trades(Vec<TradeTick>),
1204    /// Parsed quote tick (from BBO).
1205    Quote(QuoteTick),
1206    /// Parsed order book deltas.
1207    Deltas(OrderBookDeltas),
1208    /// Parsed order book depth-10 snapshot.
1209    Depth10(Box<OrderBookDepth10>),
1210    /// Parsed candle/bar.
1211    Candle(Bar),
1212    /// Mark price update.
1213    MarkPrice(MarkPriceUpdate),
1214    /// Index price update.
1215    IndexPrice(IndexPriceUpdate),
1216    /// Funding rate update.
1217    FundingRate(FundingRateUpdate),
1218    /// Custom data (e.g. allMids).
1219    CustomData(Data),
1220    /// Error occurred.
1221    Error(String),
1222    /// WebSocket reconnected.
1223    Reconnected,
1224}
1225
1226/// Execution report wrapper for order status and fill reports.
1227///
1228/// This enum allows both order status updates and fill reports.
1229/// to be sent through the execution engine.
1230#[derive(Debug, Clone)]
1231#[allow(
1232    clippy::large_enum_variant,
1233    reason = "the variant size gap only crosses the threshold when high-precision widens the raw types"
1234)]
1235pub enum ExecutionReport {
1236    /// Order status report.
1237    Order(OrderStatusReport),
1238    /// Fill report.
1239    Fill(FillReport),
1240}