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