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