nautilus_hyperliquid/websocket/
messages.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::collections::HashMap;
17
18use serde::{Deserialize, Serialize};
19
20/// Represents an outbound WebSocket message from client to Hyperliquid.
21#[derive(Debug, Clone, Serialize)]
22#[serde(tag = "method")]
23#[serde(rename_all = "lowercase")]
24pub enum HyperliquidWsRequest {
25    /// Subscribe to a data feed.
26    Subscribe {
27        /// Subscription details.
28        subscription: SubscriptionRequest,
29    },
30    /// Unsubscribe from a data feed.
31    Unsubscribe {
32        /// Subscription details to remove.
33        subscription: SubscriptionRequest,
34    },
35    /// Post a request (info or action).
36    Post {
37        /// Request ID for tracking.
38        id: u64,
39        /// Request payload.
40        request: PostRequest,
41    },
42    /// Ping for keepalive.
43    Ping,
44}
45
46/// Represents subscription request types for WebSocket feeds.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48#[serde(tag = "type")]
49#[serde(rename_all = "camelCase")]
50pub enum SubscriptionRequest {
51    /// All mid prices across markets.
52    AllMids {
53        #[serde(skip_serializing_if = "Option::is_none")]
54        dex: Option<String>,
55    },
56    /// Notifications for a user
57    Notification { user: String },
58    /// Web data for frontend
59    WebData2 { user: String },
60    /// Candlestick data
61    Candle { coin: String, interval: String },
62    /// Level 2 order book
63    L2Book {
64        coin: String,
65        #[serde(skip_serializing_if = "Option::is_none")]
66        #[serde(rename = "nSigFigs")]
67        n_sig_figs: Option<u32>,
68        #[serde(skip_serializing_if = "Option::is_none")]
69        mantissa: Option<u32>,
70    },
71    /// Trade updates
72    Trades { coin: String },
73    /// Order updates for a user
74    OrderUpdates { user: String },
75    /// User events (fills, funding, liquidations)
76    UserEvents { user: String },
77    /// User fill history
78    UserFills {
79        user: String,
80        #[serde(skip_serializing_if = "Option::is_none")]
81        #[serde(rename = "aggregateByTime")]
82        aggregate_by_time: Option<bool>,
83    },
84    /// User funding payments
85    UserFundings { user: String },
86    /// User ledger updates (non-funding)
87    UserNonFundingLedgerUpdates { user: String },
88    /// Active asset context
89    ActiveAssetCtx { coin: String },
90    /// Active asset data for user
91    ActiveAssetData { user: String, coin: String },
92    /// TWAP slice fills
93    UserTwapSliceFills { user: String },
94    /// TWAP history
95    UserTwapHistory { user: String },
96    /// Best bid/offer updates
97    Bbo { coin: String },
98}
99
100/// Post request wrapper for info and action requests
101#[derive(Debug, Clone, Serialize)]
102#[serde(tag = "type")]
103#[serde(rename_all = "lowercase")]
104pub enum PostRequest {
105    /// Info request (no signature required)
106    Info { payload: serde_json::Value },
107    /// Action request (requires signature)
108    Action { payload: ActionPayload },
109}
110
111/// Action payload with signature
112#[derive(Debug, Clone, Serialize)]
113pub struct ActionPayload {
114    pub action: ActionRequest,
115    pub nonce: u64,
116    pub signature: SignatureData,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    #[serde(rename = "vaultAddress")]
119    pub vault_address: Option<String>,
120}
121
122/// Signature data
123#[derive(Debug, Clone, Serialize)]
124pub struct SignatureData {
125    pub r: String,
126    pub s: String,
127    pub v: String,
128}
129
130/// Action request types
131#[derive(Debug, Clone, Serialize)]
132#[serde(tag = "type")]
133#[serde(rename_all = "lowercase")]
134pub enum ActionRequest {
135    /// Place orders
136    Order {
137        orders: Vec<OrderRequest>,
138        grouping: String,
139    },
140    /// Cancel orders
141    Cancel { cancels: Vec<CancelRequest> },
142    /// Cancel orders by client order ID
143    CancelByCloid { cancels: Vec<CancelByCloidRequest> },
144    /// Modify orders
145    Modify { modifies: Vec<ModifyRequest> },
146}
147
148/// Order placement request
149#[derive(Debug, Clone, Serialize)]
150pub struct OrderRequest {
151    /// Asset ID
152    pub a: u32,
153    /// Buy side (true = buy, false = sell)
154    pub b: bool,
155    /// Price
156    pub p: String,
157    /// Size
158    pub s: String,
159    /// Reduce only
160    pub r: bool,
161    /// Order type
162    pub t: OrderTypeRequest,
163    /// Client order ID (optional)
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub c: Option<String>,
166}
167
168/// Order type in request format
169#[derive(Debug, Clone, Serialize)]
170#[serde(tag = "type")]
171#[serde(rename_all = "lowercase")]
172pub enum OrderTypeRequest {
173    Limit {
174        tif: TimeInForceRequest,
175    },
176    Trigger {
177        #[serde(rename = "isMarket")]
178        is_market: bool,
179        #[serde(rename = "triggerPx")]
180        trigger_px: String,
181        tpsl: TpSlRequest,
182    },
183}
184
185/// Time in force in request format
186#[derive(Debug, Clone, Serialize)]
187#[serde(rename_all = "PascalCase")]
188pub enum TimeInForceRequest {
189    Alo,
190    Ioc,
191    Gtc,
192}
193
194/// TP/SL in request format
195#[derive(Debug, Clone, Serialize)]
196#[serde(rename_all = "lowercase")]
197pub enum TpSlRequest {
198    Tp,
199    Sl,
200}
201
202/// Cancel order request
203#[derive(Debug, Clone, Serialize)]
204pub struct CancelRequest {
205    /// Asset ID
206    pub a: u32,
207    /// Order ID
208    pub o: u64,
209}
210
211/// Cancel by client order ID request
212#[derive(Debug, Clone, Serialize)]
213pub struct CancelByCloidRequest {
214    /// Asset ID
215    pub asset: u32,
216    /// Client order ID
217    pub cloid: String,
218}
219
220/// Modify order request
221#[derive(Debug, Clone, Serialize)]
222pub struct ModifyRequest {
223    /// Order ID
224    pub oid: u64,
225    /// New order details
226    pub order: OrderRequest,
227}
228
229/// Inbound WebSocket message from Hyperliquid server
230#[derive(Debug, Clone, Deserialize)]
231#[serde(tag = "channel")]
232#[serde(rename_all = "camelCase")]
233pub enum HyperliquidWsMessage {
234    /// Subscription confirmation
235    SubscriptionResponse { data: SubscriptionRequest },
236    /// Post request response
237    Post { data: PostResponse },
238    /// All mid prices
239    AllMids { data: AllMidsData },
240    /// Notifications
241    Notification { data: NotificationData },
242    /// Web data
243    WebData2 { data: serde_json::Value },
244    /// Candlestick data
245    Candle { data: Vec<CandleData> },
246    /// Level 2 order book
247    L2Book { data: WsBookData },
248    /// Trade updates
249    Trades { data: Vec<WsTradeData> },
250    /// Order updates
251    OrderUpdates { data: Vec<WsOrderData> },
252    /// User events
253    UserEvents { data: WsUserEventData },
254    /// User fills
255    UserFills { data: WsUserFillsData },
256    /// User funding payments
257    UserFundings { data: WsUserFundingsData },
258    /// User ledger updates
259    UserNonFundingLedgerUpdates { data: serde_json::Value },
260    /// Active asset context
261    ActiveAssetCtx { data: WsActiveAssetCtxData },
262    /// Active asset data
263    ActiveAssetData { data: WsActiveAssetData },
264    /// TWAP slice fills
265    UserTwapSliceFills { data: WsUserTwapSliceFillsData },
266    /// TWAP history
267    UserTwapHistory { data: WsUserTwapHistoryData },
268    /// Best bid/offer
269    Bbo { data: WsBboData },
270    /// Pong response
271    Pong,
272}
273
274/// Post response data
275#[derive(Debug, Clone, Deserialize)]
276pub struct PostResponse {
277    pub id: u64,
278    pub response: PostResponsePayload,
279}
280
281/// Post response payload
282#[derive(Debug, Clone, Deserialize)]
283#[serde(tag = "type")]
284#[serde(rename_all = "lowercase")]
285pub enum PostResponsePayload {
286    Info { payload: serde_json::Value },
287    Action { payload: serde_json::Value },
288    Error { payload: String },
289}
290
291/// All mid prices data
292#[derive(Debug, Clone, Deserialize)]
293pub struct AllMidsData {
294    pub mids: HashMap<String, String>,
295}
296
297/// Notification data
298#[derive(Debug, Clone, Deserialize)]
299pub struct NotificationData {
300    pub notification: String,
301}
302
303/// Candlestick data
304#[derive(Debug, Clone, Deserialize)]
305pub struct CandleData {
306    /// Open time (millis)
307    pub t: u64,
308    /// Close time (millis)
309    #[serde(rename = "T")]
310    pub close_time: u64,
311    /// Symbol
312    pub s: String,
313    /// Interval
314    pub i: String,
315    /// Open price
316    pub o: f64,
317    /// Close price
318    pub c: f64,
319    /// High price
320    pub h: f64,
321    /// Low price
322    pub l: f64,
323    /// Volume
324    pub v: f64,
325    /// Number of trades
326    pub n: u32,
327}
328
329/// WebSocket book data
330#[derive(Debug, Clone, Deserialize)]
331pub struct WsBookData {
332    pub coin: String,
333    pub levels: [Vec<WsLevelData>; 2], // [bids, asks]
334    pub time: u64,
335}
336
337/// WebSocket level data
338#[derive(Debug, Clone, Deserialize)]
339pub struct WsLevelData {
340    /// Price
341    pub px: String,
342    /// Size
343    pub sz: String,
344    /// Number of orders
345    pub n: u32,
346}
347
348/// WebSocket trade data
349#[derive(Debug, Clone, Deserialize)]
350pub struct WsTradeData {
351    pub coin: String,
352    pub side: String,
353    pub px: String,
354    pub sz: String,
355    pub hash: String,
356    pub time: u64,
357    pub tid: u64,
358    pub users: [String; 2], // [buyer, seller]
359}
360
361/// WebSocket order data
362#[derive(Debug, Clone, Deserialize)]
363pub struct WsOrderData {
364    pub order: WsBasicOrderData,
365    pub status: String,
366    #[serde(rename = "statusTimestamp")]
367    pub status_timestamp: u64,
368}
369
370/// Basic order data
371#[derive(Debug, Clone, Deserialize)]
372pub struct WsBasicOrderData {
373    pub coin: String,
374    pub side: String,
375    #[serde(rename = "limitPx")]
376    pub limit_px: String,
377    pub sz: String,
378    pub oid: u64,
379    pub timestamp: u64,
380    #[serde(rename = "origSz")]
381    pub orig_sz: String,
382    pub cloid: Option<String>,
383}
384
385/// WebSocket user event data
386#[derive(Debug, Clone, Deserialize)]
387#[serde(untagged)]
388pub enum WsUserEventData {
389    Fills {
390        fills: Vec<WsFillData>,
391    },
392    Funding {
393        funding: WsUserFundingData,
394    },
395    Liquidation {
396        liquidation: WsLiquidationData,
397    },
398    NonUserCancel {
399        #[serde(rename = "nonUserCancel")]
400        non_user_cancel: Vec<WsNonUserCancelData>,
401    },
402}
403
404/// WebSocket fill data
405#[derive(Debug, Clone, Deserialize)]
406pub struct WsFillData {
407    pub coin: String,
408    pub px: String,
409    pub sz: String,
410    pub side: String,
411    pub time: u64,
412    #[serde(rename = "startPosition")]
413    pub start_position: String,
414    pub dir: String,
415    #[serde(rename = "closedPnl")]
416    pub closed_pnl: String,
417    pub hash: String,
418    pub oid: u64,
419    pub crossed: bool,
420    pub fee: String,
421    pub tid: u64,
422    pub liquidation: Option<FillLiquidationData>,
423    #[serde(rename = "feeToken")]
424    pub fee_token: String,
425    #[serde(rename = "builderFee")]
426    pub builder_fee: Option<String>,
427}
428
429/// Fill liquidation data
430#[derive(Debug, Clone, Deserialize)]
431pub struct FillLiquidationData {
432    #[serde(rename = "liquidatedUser")]
433    pub liquidated_user: Option<String>,
434    #[serde(rename = "markPx")]
435    pub mark_px: f64,
436    pub method: String, // "market" | "backstop"
437}
438
439/// WebSocket user funding data
440#[derive(Debug, Clone, Deserialize)]
441pub struct WsUserFundingData {
442    pub time: u64,
443    pub coin: String,
444    pub usdc: String,
445    pub szi: String,
446    #[serde(rename = "fundingRate")]
447    pub funding_rate: String,
448}
449
450/// WebSocket liquidation data
451#[derive(Debug, Clone, Deserialize)]
452pub struct WsLiquidationData {
453    pub lid: u64,
454    pub liquidator: String,
455    pub liquidated_user: String,
456    pub liquidated_ntl_pos: String,
457    pub liquidated_account_value: String,
458}
459
460/// WebSocket non-user cancel data
461#[derive(Debug, Clone, Deserialize)]
462pub struct WsNonUserCancelData {
463    pub coin: String,
464    pub oid: u64,
465}
466
467/// WebSocket user fills data
468#[derive(Debug, Clone, Deserialize)]
469pub struct WsUserFillsData {
470    #[serde(rename = "isSnapshot")]
471    pub is_snapshot: Option<bool>,
472    pub user: String,
473    pub fills: Vec<WsFillData>,
474}
475
476/// WebSocket user fundings data
477#[derive(Debug, Clone, Deserialize)]
478pub struct WsUserFundingsData {
479    #[serde(rename = "isSnapshot")]
480    pub is_snapshot: Option<bool>,
481    pub user: String,
482    pub fundings: Vec<WsUserFundingData>,
483}
484
485/// WebSocket active asset context data
486#[derive(Debug, Clone, Deserialize)]
487#[serde(untagged)]
488pub enum WsActiveAssetCtxData {
489    Perp { coin: String, ctx: PerpsAssetCtx },
490    Spot { coin: String, ctx: SpotAssetCtx },
491}
492
493/// Shared asset context fields
494#[derive(Debug, Clone, Deserialize)]
495pub struct SharedAssetCtx {
496    #[serde(rename = "dayNtlVlm")]
497    pub day_ntl_vlm: f64,
498    #[serde(rename = "prevDayPx")]
499    pub prev_day_px: f64,
500    #[serde(rename = "markPx")]
501    pub mark_px: f64,
502    #[serde(rename = "midPx")]
503    pub mid_px: Option<f64>,
504}
505
506/// Perps asset context
507#[derive(Debug, Clone, Deserialize)]
508pub struct PerpsAssetCtx {
509    #[serde(flatten)]
510    pub shared: SharedAssetCtx,
511    pub funding: f64,
512    #[serde(rename = "openInterest")]
513    pub open_interest: f64,
514    #[serde(rename = "oraclePx")]
515    pub oracle_px: f64,
516}
517
518/// Spot asset context
519#[derive(Debug, Clone, Deserialize)]
520pub struct SpotAssetCtx {
521    #[serde(flatten)]
522    pub shared: SharedAssetCtx,
523    #[serde(rename = "circulatingSupply")]
524    pub circulating_supply: f64,
525}
526
527/// WebSocket active asset data
528#[derive(Debug, Clone, Deserialize)]
529pub struct WsActiveAssetData {
530    pub user: String,
531    pub coin: String,
532    pub leverage: LeverageData,
533    #[serde(rename = "maxTradeSzs")]
534    pub max_trade_szs: [f64; 2],
535    #[serde(rename = "availableToTrade")]
536    pub available_to_trade: [f64; 2],
537}
538
539/// Leverage data
540#[derive(Debug, Clone, Deserialize)]
541pub struct LeverageData {
542    pub value: f64,
543    pub type_: String,
544}
545
546/// WebSocket TWAP slice fills data
547#[derive(Debug, Clone, Deserialize)]
548pub struct WsUserTwapSliceFillsData {
549    #[serde(rename = "isSnapshot")]
550    pub is_snapshot: Option<bool>,
551    pub user: String,
552    #[serde(rename = "twapSliceFills")]
553    pub twap_slice_fills: Vec<WsTwapSliceFillData>,
554}
555
556/// TWAP slice fill data
557#[derive(Debug, Clone, Deserialize)]
558pub struct WsTwapSliceFillData {
559    pub fill: WsFillData,
560    #[serde(rename = "twapId")]
561    pub twap_id: u64,
562}
563
564/// WebSocket TWAP history data
565#[derive(Debug, Clone, Deserialize)]
566pub struct WsUserTwapHistoryData {
567    #[serde(rename = "isSnapshot")]
568    pub is_snapshot: Option<bool>,
569    pub user: String,
570    pub history: Vec<WsTwapHistoryData>,
571}
572
573/// TWAP history data
574#[derive(Debug, Clone, Deserialize)]
575pub struct WsTwapHistoryData {
576    pub state: TwapStateData,
577    pub status: TwapStatusData,
578    pub time: u64,
579}
580
581/// TWAP state data
582#[derive(Debug, Clone, Deserialize)]
583pub struct TwapStateData {
584    pub coin: String,
585    pub user: String,
586    pub side: String,
587    pub sz: f64,
588    #[serde(rename = "executedSz")]
589    pub executed_sz: f64,
590    #[serde(rename = "executedNtl")]
591    pub executed_ntl: f64,
592    pub minutes: u32,
593    #[serde(rename = "reduceOnly")]
594    pub reduce_only: bool,
595    pub randomize: bool,
596    pub timestamp: u64,
597}
598
599/// TWAP status data
600#[derive(Debug, Clone, Deserialize)]
601pub struct TwapStatusData {
602    pub status: String, // "activated" | "terminated" | "finished" | "error"
603    pub description: String,
604}
605
606/// WebSocket BBO data
607#[derive(Debug, Clone, Deserialize)]
608pub struct WsBboData {
609    pub coin: String,
610    pub time: u64,
611    pub bbo: [Option<WsLevelData>; 2], // [bid, ask]
612}
613
614////////////////////////////////////////////////////////////////////////////////
615// Tests
616////////////////////////////////////////////////////////////////////////////////
617
618#[cfg(test)]
619mod tests {
620    use rstest::rstest;
621    use serde_json;
622
623    use super::*;
624
625    #[rstest]
626    fn test_subscription_request_serialization() {
627        let sub = SubscriptionRequest::L2Book {
628            coin: "BTC".to_string(),
629            n_sig_figs: Some(5),
630            mantissa: None,
631        };
632
633        let json = serde_json::to_string(&sub).unwrap();
634        assert!(json.contains(r#""type":"l2Book""#));
635        assert!(json.contains(r#""coin":"BTC""#));
636    }
637
638    #[rstest]
639    fn test_hyperliquid_ws_request_serialization() {
640        let req = HyperliquidWsRequest::Subscribe {
641            subscription: SubscriptionRequest::Trades {
642                coin: "ETH".to_string(),
643            },
644        };
645
646        let json = serde_json::to_string(&req).unwrap();
647        assert!(json.contains(r#""method":"subscribe""#));
648        assert!(json.contains(r#""type":"trades""#));
649    }
650
651    #[rstest]
652    fn test_order_request_serialization() {
653        let order = OrderRequest {
654            a: 0,    // BTC asset ID
655            b: true, // buy
656            p: "50000.0".to_string(),
657            s: "0.1".to_string(),
658            r: false,
659            t: OrderTypeRequest::Limit {
660                tif: TimeInForceRequest::Gtc,
661            },
662            c: Some("client-123".to_string()),
663        };
664
665        let json = serde_json::to_string(&order).unwrap();
666        assert!(json.contains(r#""a":0"#));
667        assert!(json.contains(r#""b":true"#));
668        assert!(json.contains(r#""p":"50000.0""#));
669    }
670
671    #[rstest]
672    fn test_ws_trade_data_deserialization() {
673        let json = r#"{
674            "coin": "BTC",
675            "side": "B",
676            "px": "50000.0",
677            "sz": "0.1",
678            "hash": "0x123",
679            "time": 1234567890,
680            "tid": 12345,
681            "users": ["0xabc", "0xdef"]
682        }"#;
683
684        let trade: WsTradeData = serde_json::from_str(json).unwrap();
685        assert_eq!(trade.coin, "BTC");
686        assert_eq!(trade.side, "B");
687        assert_eq!(trade.px, "50000.0");
688    }
689
690    #[rstest]
691    fn test_ws_book_data_deserialization() {
692        let json = r#"{
693            "coin": "ETH",
694            "levels": [
695                [{"px": "3000.0", "sz": "1.0", "n": 1}],
696                [{"px": "3001.0", "sz": "2.0", "n": 2}]
697            ],
698            "time": 1234567890
699        }"#;
700
701        let book: WsBookData = serde_json::from_str(json).unwrap();
702        assert_eq!(book.coin, "ETH");
703        assert_eq!(book.levels[0].len(), 1);
704        assert_eq!(book.levels[1].len(), 1);
705    }
706}