1use 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#[derive(Debug, Clone, Serialize)]
43#[serde(tag = "method")]
44#[serde(rename_all = "lowercase")]
45pub enum HyperliquidWsRequest {
46 Subscribe {
48 subscription: SubscriptionRequest,
50 },
51 Unsubscribe {
53 subscription: SubscriptionRequest,
55 },
56 Post {
58 id: u64,
60 request: PostRequest,
62 },
63 Ping,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69#[serde(tag = "type")]
70#[serde(rename_all = "camelCase")]
71pub enum SubscriptionRequest {
72 AllMids {
74 #[serde(skip_serializing_if = "Option::is_none")]
75 dex: Option<String>,
76 },
77 AllDexsAssetCtxs,
79 Notification { user: String },
81 WebData2 { user: String },
83 Candle {
85 coin: Ustr,
86 interval: HyperliquidBarInterval,
87 },
88 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 Trades { coin: Ustr },
99 OrderUpdates { user: String },
101 UserEvents { user: String },
103 UserFills {
105 user: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 #[serde(rename = "aggregateByTime")]
108 aggregate_by_time: Option<bool>,
109 },
110 UserFundings { user: String },
112 UserNonFundingLedgerUpdates { user: String },
114 ActiveAssetCtx { coin: Ustr },
116 ActiveSpotAssetCtx { coin: Ustr },
118 ActiveAssetData { user: String, coin: String },
120 UserTwapSliceFills { user: String },
122 UserTwapHistory { user: String },
124 Bbo { coin: Ustr },
126}
127
128#[derive(Debug, Clone, Serialize)]
130#[serde(tag = "type")]
131#[serde(rename_all = "lowercase")]
132pub enum PostRequest {
133 Info { payload: serde_json::Value },
135 Action {
137 payload: HyperliquidExchangeRequest<HyperliquidExecAction>,
138 },
139}
140
141#[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#[derive(Debug, Clone, Serialize)]
154pub struct SignatureData {
155 pub r: String,
156 pub s: String,
157 pub v: String,
158}
159
160#[derive(Debug, Clone, Serialize)]
162#[serde(tag = "type")]
163#[serde(rename_all = "lowercase")]
164pub enum ActionRequest {
165 Order {
167 orders: Vec<OrderRequest>,
168 grouping: String,
169 },
170 Cancel { cancels: Vec<CancelRequest> },
172 CancelByCloid { cancels: Vec<CancelByCloidRequest> },
174 Modify { modifies: Vec<ModifyRequest> },
176}
177
178impl ActionRequest {
179 pub fn order(orders: Vec<OrderRequest>, grouping: impl Into<String>) -> Self {
186 Self::Order {
187 orders,
188 grouping: grouping.into(),
189 }
190 }
191
192 pub fn cancel(cancels: Vec<CancelRequest>) -> Self {
202 Self::Cancel { cancels }
203 }
204
205 pub fn cancel_by_cloid(cancels: Vec<CancelByCloidRequest>) -> Self {
214 Self::CancelByCloid { cancels }
215 }
216
217 pub fn modify(modifies: Vec<ModifyRequest>) -> Self {
226 Self::Modify { modifies }
227 }
228}
229
230#[derive(Debug, Clone, Serialize, Builder)]
232pub struct OrderRequest {
233 pub a: u32,
235 pub b: bool,
237 pub p: String,
239 pub s: String,
241 pub r: bool,
243 pub t: OrderTypeRequest,
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub c: Option<String>,
248}
249
250#[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#[derive(Debug, Clone, Serialize)]
269#[serde(rename_all = "PascalCase")]
270pub enum TimeInForceRequest {
271 Alo,
272 Ioc,
273 Gtc,
274}
275
276#[derive(Debug, Clone, Serialize)]
278#[serde(rename_all = "lowercase")]
279pub enum TpSlRequest {
280 Tp,
281 Sl,
282}
283
284#[derive(Debug, Clone, Serialize)]
286pub struct CancelRequest {
287 pub a: u32,
289 pub o: u64,
291}
292
293#[derive(Debug, Clone, Serialize)]
295pub struct CancelByCloidRequest {
296 pub asset: u32,
298 pub cloid: String,
300}
301
302#[derive(Debug, Clone, Serialize)]
304pub struct ModifyRequest {
305 pub oid: u64,
307 pub order: OrderRequest,
309}
310
311#[derive(Debug, Clone, Deserialize)]
313pub struct SubscriptionResponseData {
314 pub method: String,
315 pub subscription: SubscriptionRequest,
316}
317
318#[derive(Debug, Clone, Deserialize)]
320#[serde(tag = "channel")]
321#[serde(rename_all = "camelCase")]
322pub enum HyperliquidWsMessage {
323 SubscriptionResponse { data: SubscriptionResponseData },
325 Post { data: PostResponse },
327 AllMids { data: AllMidsData },
329 AllDexsAssetCtxs { data: WsAllDexsAssetCtxsData },
331 Notification { data: NotificationData },
333 WebData2 { data: serde_json::Value },
335 Candle { data: CandleData },
337 L2Book { data: WsBookData },
339 Trades { data: Vec<WsTradeData> },
341 OrderUpdates { data: Vec<WsOrderData> },
343 UserEvents { data: WsUserEventData },
345 #[serde(rename = "user")]
347 User { data: WsUserEventData },
348 UserFills { data: WsUserFillsData },
350 UserFundings { data: WsUserFundingsData },
352 UserNonFundingLedgerUpdates { data: serde_json::Value },
354 ActiveAssetCtx { data: WsActiveAssetCtxData },
356 ActiveSpotAssetCtx { data: WsActiveAssetCtxData },
358 ActiveAssetData { data: WsActiveAssetData },
360 UserTwapSliceFills { data: WsUserTwapSliceFillsData },
362 UserTwapHistory { data: WsUserTwapHistoryData },
364 Bbo { data: WsBboData },
366 Error { data: String },
368 Pong,
370}
371
372#[derive(Debug, Clone, Deserialize)]
374pub struct PostResponse {
375 pub id: u64,
376 pub response: PostResponsePayload,
377}
378
379#[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#[derive(Debug, Clone, Deserialize)]
391pub struct AllMidsData {
392 pub mids: AHashMap<Ustr, String>,
393}
394
395#[derive(Debug, Clone, Deserialize)]
397pub struct WsAllDexsAssetCtxsData {
398 pub ctxs: Vec<(String, Vec<PerpsAssetCtx>)>,
399}
400
401#[derive(Debug, Clone, Deserialize)]
403pub struct NotificationData {
404 pub notification: String,
405}
406
407#[derive(Debug, Clone, Deserialize)]
409pub struct CandleData {
410 pub t: u64,
412 #[serde(rename = "T")]
414 pub close_time: u64,
415 pub s: Ustr,
417 pub i: Ustr,
419 #[serde(deserialize_with = "deserialize_decimal_from_str")]
421 pub o: Decimal,
422 #[serde(deserialize_with = "deserialize_decimal_from_str")]
424 pub c: Decimal,
425 #[serde(deserialize_with = "deserialize_decimal_from_str")]
427 pub h: Decimal,
428 #[serde(deserialize_with = "deserialize_decimal_from_str")]
430 pub l: Decimal,
431 #[serde(deserialize_with = "deserialize_decimal_from_str")]
433 pub v: Decimal,
434 pub n: u32,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct WsBookData {
441 pub coin: Ustr,
442 pub levels: [Vec<WsLevelData>; 2], pub time: u64,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct WsLevelData {
449 #[serde(
451 deserialize_with = "deserialize_decimal_from_str",
452 serialize_with = "serialize_decimal_as_str"
453 )]
454 pub px: Decimal,
455 #[serde(
457 deserialize_with = "deserialize_decimal_from_str",
458 serialize_with = "serialize_decimal_as_str"
459 )]
460 pub sz: Decimal,
461 pub n: u32,
463}
464
465#[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], }
485
486#[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#[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 #[serde(
514 rename = "triggerPx",
515 default,
516 deserialize_with = "deserialize_optional_decimal_from_str"
517 )]
518 pub trigger_px: Option<Decimal>,
519 #[serde(rename = "isMarket")]
521 pub is_market: Option<bool>,
522 pub tpsl: Option<HyperliquidTpSl>,
524 #[serde(rename = "triggerActivated")]
526 pub trigger_activated: Option<bool>,
527 #[serde(rename = "trailingStop")]
529 pub trailing_stop: Option<WsTrailingStopData>,
530}
531
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
534#[serde(rename_all = "camelCase")]
535pub enum TrailingOffsetType {
536 Price,
538 Percentage,
540 BasisPoints,
542}
543
544impl TrailingOffsetType {
545 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#[derive(Debug, Clone, Deserialize)]
557pub struct WsTrailingStopData {
558 #[serde(deserialize_with = "deserialize_decimal_from_str")]
560 pub offset: Decimal,
561 #[serde(rename = "offsetType")]
563 pub offset_type: TrailingOffsetType,
564 #[serde(
566 rename = "callbackPrice",
567 default,
568 deserialize_with = "deserialize_optional_decimal_from_str"
569 )]
570 pub callback_price: Option<Decimal>,
571}
572
573#[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 TriggerActivated {
592 #[serde(rename = "triggerActivated")]
593 trigger_activated: WsTriggerActivatedData,
594 },
595 TriggerTriggered {
597 #[serde(rename = "triggerTriggered")]
598 trigger_triggered: WsTriggerTriggeredData,
599 },
600}
601
602#[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 pub cloid: Option<String>,
641 #[serde(rename = "twapId")]
643 pub twap_id: Option<serde_json::Value>,
644}
645
646#[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#[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#[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#[derive(Debug, Clone, Deserialize)]
686pub struct WsNonUserCancelData {
687 pub coin: Ustr,
688 pub oid: u64,
689}
690
691#[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#[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 #[serde(rename = "resultingOid")]
721 pub resulting_oid: Option<u64>,
722}
723
724#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Deserialize)]
825pub struct LeverageData {
826 pub value: f64,
827 pub type_: String,
828}
829
830#[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#[derive(Debug, Clone, Deserialize)]
842pub struct WsTwapSliceFillData {
843 pub fill: WsFillData,
844 #[serde(rename = "twapId")]
845 pub twap_id: u64,
846}
847
848#[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#[derive(Debug, Clone, Deserialize)]
859pub struct WsTwapHistoryData {
860 pub state: TwapStateData,
861 pub status: TwapStatusData,
862 pub time: u64,
863}
864
865#[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#[derive(Debug, Clone, Deserialize)]
885pub struct TwapStatusData {
886 pub status: HyperliquidTwapStatus,
887 pub description: String,
888}
889
890#[derive(Debug, Clone, Deserialize)]
892pub struct WsBboData {
893 pub coin: Ustr,
894 pub time: u64,
895 pub bbo: [Option<WsLevelData>; 2], }
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, b: true, 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 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 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 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#[derive(Debug, Clone)]
1178pub enum NautilusWsMessage {
1179 ExecutionReports(Vec<ExecutionReport>),
1181 Trades(Vec<TradeTick>),
1183 Quote(QuoteTick),
1185 Deltas(OrderBookDeltas),
1187 Depth10(Box<OrderBookDepth10>),
1189 Candle(Bar),
1191 MarkPrice(MarkPriceUpdate),
1193 IndexPrice(IndexPriceUpdate),
1195 FundingRate(FundingRateUpdate),
1197 CustomData(Data),
1199 Error(String),
1201 Reconnected,
1203}
1204
1205#[derive(Debug, Clone)]
1210#[expect(clippy::large_enum_variant)]
1211pub enum ExecutionReport {
1212 Order(OrderStatusReport),
1214 Fill(FillReport),
1216}