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