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