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 {
172 cancels: Vec<CancelRequest>,
173 #[serde(rename = "f", skip_serializing_if = "Option::is_none")]
174 fast: Option<bool>,
175 },
176 CancelByCloid {
178 cancels: Vec<CancelByCloidRequest>,
179 #[serde(rename = "f", skip_serializing_if = "Option::is_none")]
180 fast: Option<bool>,
181 },
182 Modify { modifies: Vec<ModifyRequest> },
184}
185
186impl ActionRequest {
187 pub fn order(orders: Vec<OrderRequest>, grouping: impl Into<String>) -> Self {
194 Self::Order {
195 orders,
196 grouping: grouping.into(),
197 }
198 }
199
200 pub fn cancel(cancels: Vec<CancelRequest>) -> Self {
210 Self::Cancel {
211 cancels,
212 fast: None,
213 }
214 }
215
216 pub fn cancel_by_cloid(cancels: Vec<CancelByCloidRequest>) -> Self {
225 Self::CancelByCloid {
226 cancels,
227 fast: None,
228 }
229 }
230
231 pub fn modify(modifies: Vec<ModifyRequest>) -> Self {
240 Self::Modify { modifies }
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Builder)]
246pub struct OrderRequest {
247 pub a: u32,
249 pub b: bool,
251 pub p: String,
253 pub s: String,
255 pub r: bool,
257 pub t: OrderTypeRequest,
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub c: Option<String>,
262}
263
264#[derive(Debug, Clone, Serialize)]
266#[serde(tag = "type")]
267#[serde(rename_all = "lowercase")]
268pub enum OrderTypeRequest {
269 Limit {
270 tif: TimeInForceRequest,
271 },
272 Trigger {
273 #[serde(rename = "isMarket")]
274 is_market: bool,
275 #[serde(rename = "triggerPx")]
276 trigger_px: String,
277 tpsl: TpSlRequest,
278 },
279}
280
281#[derive(Debug, Clone, Serialize)]
283#[serde(rename_all = "PascalCase")]
284pub enum TimeInForceRequest {
285 Alo,
286 Ioc,
287 Gtc,
288}
289
290#[derive(Debug, Clone, Serialize)]
292#[serde(rename_all = "lowercase")]
293pub enum TpSlRequest {
294 Tp,
295 Sl,
296}
297
298#[derive(Debug, Clone, Serialize)]
300pub struct CancelRequest {
301 pub a: u32,
303 pub o: u64,
305}
306
307#[derive(Debug, Clone, Serialize)]
309pub struct CancelByCloidRequest {
310 pub asset: u32,
312 pub cloid: String,
314}
315
316#[derive(Debug, Clone, Serialize)]
318pub struct ModifyRequest {
319 pub oid: u64,
321 pub order: OrderRequest,
323}
324
325#[derive(Debug, Clone, Deserialize)]
327pub struct SubscriptionResponseData {
328 pub method: String,
329 pub subscription: SubscriptionRequest,
330}
331
332#[derive(Debug, Clone, Deserialize)]
334#[serde(tag = "channel")]
335#[serde(rename_all = "camelCase")]
336pub enum HyperliquidWsMessage {
337 SubscriptionResponse { data: SubscriptionResponseData },
339 Post { data: PostResponse },
341 AllMids { data: AllMidsData },
343 AllDexsAssetCtxs { data: WsAllDexsAssetCtxsData },
345 Notification { data: NotificationData },
347 WebData2 { data: serde_json::Value },
349 Candle { data: CandleData },
351 L2Book { data: WsBookData },
353 Trades { data: Vec<WsTradeData> },
355 OrderUpdates { data: Vec<WsOrderData> },
357 UserEvents { data: WsUserEventData },
359 #[serde(rename = "user")]
361 User { data: WsUserEventData },
362 UserFills { data: WsUserFillsData },
364 UserFundings { data: WsUserFundingsData },
366 UserNonFundingLedgerUpdates { data: serde_json::Value },
368 ActiveAssetCtx { data: WsActiveAssetCtxData },
370 ActiveSpotAssetCtx { data: WsActiveAssetCtxData },
372 ActiveAssetData { data: WsActiveAssetData },
374 UserTwapSliceFills { data: WsUserTwapSliceFillsData },
376 UserTwapHistory { data: WsUserTwapHistoryData },
378 Bbo { data: WsBboData },
380 Error { data: String },
382 Pong,
384}
385
386#[derive(Debug, Clone, Deserialize)]
388pub struct PostResponse {
389 pub id: u64,
390 pub response: PostResponsePayload,
391}
392
393#[derive(Debug, Clone, Deserialize)]
395#[serde(tag = "type")]
396#[serde(rename_all = "lowercase")]
397pub enum PostResponsePayload {
398 Info { payload: serde_json::Value },
399 Action { payload: serde_json::Value },
400 Error { payload: String },
401}
402
403#[derive(Debug, Clone, Deserialize)]
405pub struct AllMidsData {
406 pub mids: AHashMap<Ustr, String>,
407}
408
409#[derive(Debug, Clone, Deserialize)]
411pub struct WsAllDexsAssetCtxsData {
412 pub ctxs: Vec<(String, Vec<PerpsAssetCtx>)>,
413}
414
415#[derive(Debug, Clone, Deserialize)]
417pub struct NotificationData {
418 pub notification: String,
419}
420
421#[derive(Debug, Clone, Deserialize)]
423pub struct CandleData {
424 pub t: u64,
426 #[serde(rename = "T")]
428 pub close_time: u64,
429 pub s: Ustr,
431 pub i: Ustr,
433 #[serde(deserialize_with = "deserialize_decimal_from_str")]
435 pub o: Decimal,
436 #[serde(deserialize_with = "deserialize_decimal_from_str")]
438 pub c: Decimal,
439 #[serde(deserialize_with = "deserialize_decimal_from_str")]
441 pub h: Decimal,
442 #[serde(deserialize_with = "deserialize_decimal_from_str")]
444 pub l: Decimal,
445 #[serde(deserialize_with = "deserialize_decimal_from_str")]
447 pub v: Decimal,
448 pub n: u32,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct WsBookData {
455 pub coin: Ustr,
456 pub levels: [Vec<WsLevelData>; 2], pub time: u64,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct WsLevelData {
463 #[serde(
465 deserialize_with = "deserialize_decimal_from_str",
466 serialize_with = "serialize_decimal_as_str"
467 )]
468 pub px: Decimal,
469 #[serde(
471 deserialize_with = "deserialize_decimal_from_str",
472 serialize_with = "serialize_decimal_as_str"
473 )]
474 pub sz: Decimal,
475 pub n: u32,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct WsTradeData {
482 pub coin: Ustr,
483 pub side: HyperliquidSide,
484 #[serde(
485 deserialize_with = "deserialize_decimal_from_str",
486 serialize_with = "serialize_decimal_as_str"
487 )]
488 pub px: Decimal,
489 #[serde(
490 deserialize_with = "deserialize_decimal_from_str",
491 serialize_with = "serialize_decimal_as_str"
492 )]
493 pub sz: Decimal,
494 pub hash: String,
495 pub time: u64,
496 pub tid: u64,
497 pub users: [String; 2], }
499
500#[derive(Debug, Clone, Deserialize)]
502pub struct WsOrderData {
503 pub order: WsBasicOrderData,
504 pub status: HyperliquidOrderStatusEnum,
505 #[serde(rename = "statusTimestamp")]
506 pub status_timestamp: u64,
507}
508
509#[derive(Debug, Clone, Deserialize)]
511pub struct WsBasicOrderData {
512 pub coin: Ustr,
513 pub side: HyperliquidSide,
514 #[serde(rename = "limitPx", deserialize_with = "deserialize_decimal_from_str")]
515 pub limit_px: Decimal,
516 #[serde(deserialize_with = "deserialize_decimal_from_str")]
517 pub sz: Decimal,
518 pub oid: u64,
519 pub timestamp: u64,
520 #[serde(rename = "origSz", deserialize_with = "deserialize_decimal_from_str")]
521 pub orig_sz: Decimal,
522 pub cloid: Option<String>,
523 pub tif: Option<HyperliquidTimeInForce>,
524 #[serde(rename = "reduceOnly")]
525 pub reduce_only: Option<bool>,
526 #[serde(
528 rename = "triggerPx",
529 default,
530 deserialize_with = "deserialize_optional_decimal_from_str"
531 )]
532 pub trigger_px: Option<Decimal>,
533 #[serde(rename = "isMarket")]
535 pub is_market: Option<bool>,
536 pub tpsl: Option<HyperliquidTpSl>,
538 #[serde(rename = "triggerActivated")]
540 pub trigger_activated: Option<bool>,
541 #[serde(rename = "trailingStop")]
543 pub trailing_stop: Option<WsTrailingStopData>,
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
548#[serde(rename_all = "camelCase")]
549pub enum TrailingOffsetType {
550 Price,
552 Percentage,
554 BasisPoints,
556}
557
558impl TrailingOffsetType {
559 pub fn format_offset(&self, offset: &str) -> String {
561 match self {
562 Self::Price => offset.to_string(),
563 Self::Percentage => format!("{offset}%"),
564 Self::BasisPoints => format!("{offset} bps"),
565 }
566 }
567}
568
569#[derive(Debug, Clone, Deserialize)]
571pub struct WsTrailingStopData {
572 #[serde(deserialize_with = "deserialize_decimal_from_str")]
574 pub offset: Decimal,
575 #[serde(rename = "offsetType")]
577 pub offset_type: TrailingOffsetType,
578 #[serde(
580 rename = "callbackPrice",
581 default,
582 deserialize_with = "deserialize_optional_decimal_from_str"
583 )]
584 pub callback_price: Option<Decimal>,
585}
586
587#[derive(Debug, Clone, Deserialize)]
589#[serde(untagged)]
590pub enum WsUserEventData {
591 Fills {
592 fills: Vec<WsFillData>,
593 },
594 Funding {
595 funding: WsUserFundingData,
596 },
597 Liquidation {
598 liquidation: WsLiquidationData,
599 },
600 NonUserCancel {
601 #[serde(rename = "nonUserCancel")]
602 non_user_cancel: Vec<WsNonUserCancelData>,
603 },
604 TriggerActivated {
606 #[serde(rename = "triggerActivated")]
607 trigger_activated: WsTriggerActivatedData,
608 },
609 TriggerTriggered {
611 #[serde(rename = "triggerTriggered")]
612 trigger_triggered: WsTriggerTriggeredData,
613 },
614}
615
616#[derive(Debug, Clone, Deserialize)]
618pub struct WsFillData {
619 pub coin: Ustr,
620 #[serde(deserialize_with = "deserialize_decimal_from_str")]
621 pub px: Decimal,
622 #[serde(deserialize_with = "deserialize_decimal_from_str")]
623 pub sz: Decimal,
624 pub side: HyperliquidSide,
625 pub time: u64,
626 #[serde(
627 rename = "startPosition",
628 deserialize_with = "deserialize_decimal_from_str"
629 )]
630 pub start_position: Decimal,
631 pub dir: HyperliquidFillDirection,
632 #[serde(
633 rename = "closedPnl",
634 deserialize_with = "deserialize_decimal_from_str"
635 )]
636 pub closed_pnl: Decimal,
637 pub hash: String,
638 pub oid: u64,
639 pub crossed: bool,
640 #[serde(deserialize_with = "deserialize_decimal_from_str")]
641 pub fee: Decimal,
642 pub tid: u64,
643 #[serde(default)]
644 pub liquidation: Option<FillLiquidationData>,
645 #[serde(rename = "feeToken")]
646 pub fee_token: Ustr,
647 #[serde(
648 rename = "builderFee",
649 default,
650 deserialize_with = "deserialize_optional_decimal_from_str"
651 )]
652 pub builder_fee: Option<Decimal>,
653 pub cloid: Option<String>,
655 #[serde(rename = "twapId")]
657 pub twap_id: Option<serde_json::Value>,
658}
659
660#[derive(Debug, Clone, Deserialize)]
662pub struct FillLiquidationData {
663 #[serde(rename = "liquidatedUser")]
664 pub liquidated_user: Option<String>,
665 #[serde(rename = "markPx", deserialize_with = "deserialize_decimal_from_str")]
666 pub mark_px: Decimal,
667 pub method: HyperliquidLiquidationMethod,
668}
669
670#[derive(Debug, Clone, Deserialize)]
672pub struct WsUserFundingData {
673 pub time: u64,
674 pub coin: Ustr,
675 #[serde(deserialize_with = "deserialize_decimal_from_str")]
676 pub usdc: Decimal,
677 #[serde(deserialize_with = "deserialize_decimal_from_str")]
678 pub szi: Decimal,
679 #[serde(
680 rename = "fundingRate",
681 deserialize_with = "deserialize_decimal_from_str"
682 )]
683 pub funding_rate: Decimal,
684}
685
686#[derive(Debug, Clone, Deserialize)]
688pub struct WsLiquidationData {
689 pub lid: u64,
690 pub liquidator: String,
691 pub liquidated_user: String,
692 #[serde(deserialize_with = "deserialize_decimal_from_str")]
693 pub liquidated_ntl_pos: Decimal,
694 #[serde(deserialize_with = "deserialize_decimal_from_str")]
695 pub liquidated_account_value: Decimal,
696}
697
698#[derive(Debug, Clone, Deserialize)]
700pub struct WsNonUserCancelData {
701 pub coin: Ustr,
702 pub oid: u64,
703}
704
705#[derive(Debug, Clone, Deserialize)]
707pub struct WsTriggerActivatedData {
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 pub tpsl: HyperliquidTpSl,
717}
718
719#[derive(Debug, Clone, Deserialize)]
721pub struct WsTriggerTriggeredData {
722 pub coin: Ustr,
723 pub oid: u64,
724 pub time: u64,
725 #[serde(
726 rename = "triggerPx",
727 deserialize_with = "deserialize_decimal_from_str"
728 )]
729 pub trigger_px: Decimal,
730 #[serde(rename = "marketPx", deserialize_with = "deserialize_decimal_from_str")]
731 pub market_px: Decimal,
732 pub tpsl: HyperliquidTpSl,
733 #[serde(rename = "resultingOid")]
735 pub resulting_oid: Option<u64>,
736}
737
738#[derive(Debug, Clone, Deserialize)]
740pub struct WsUserFillsData {
741 #[serde(rename = "isSnapshot")]
742 pub is_snapshot: Option<bool>,
743 pub user: String,
744 pub fills: Vec<WsFillData>,
745}
746
747#[derive(Debug, Clone, Deserialize)]
749pub struct WsUserFundingsData {
750 #[serde(rename = "isSnapshot")]
751 pub is_snapshot: Option<bool>,
752 pub user: String,
753 pub fundings: Vec<WsUserFundingData>,
754}
755
756#[derive(Debug, Clone, Deserialize)]
758#[serde(untagged)]
759pub enum WsActiveAssetCtxData {
760 Perp { coin: Ustr, ctx: PerpsAssetCtx },
761 Spot { coin: Ustr, ctx: SpotAssetCtx },
762}
763
764#[derive(Debug, Clone, Deserialize)]
766pub struct SharedAssetCtx {
767 #[serde(
768 rename = "dayNtlVlm",
769 deserialize_with = "deserialize_decimal_from_str"
770 )]
771 pub day_ntl_vlm: Decimal,
772 #[serde(
773 rename = "prevDayPx",
774 deserialize_with = "deserialize_decimal_from_str"
775 )]
776 pub prev_day_px: Decimal,
777 #[serde(rename = "markPx", deserialize_with = "deserialize_decimal_from_str")]
778 pub mark_px: Decimal,
779 #[serde(
780 rename = "midPx",
781 default,
782 deserialize_with = "deserialize_optional_decimal_from_str"
783 )]
784 pub mid_px: Option<Decimal>,
785 #[serde(rename = "impactPxs")]
786 pub impact_pxs: Option<Vec<String>>,
787 #[serde(
788 rename = "dayBaseVlm",
789 default,
790 deserialize_with = "deserialize_optional_decimal_from_str"
791 )]
792 pub day_base_vlm: Option<Decimal>,
793}
794
795#[derive(Debug, Clone, Deserialize)]
797pub struct PerpsAssetCtx {
798 #[serde(flatten)]
799 pub shared: SharedAssetCtx,
800 #[serde(deserialize_with = "deserialize_decimal_from_str")]
801 pub funding: Decimal,
802 #[serde(
803 rename = "openInterest",
804 deserialize_with = "deserialize_decimal_from_str"
805 )]
806 pub open_interest: Decimal,
807 #[serde(rename = "oraclePx", deserialize_with = "deserialize_decimal_from_str")]
808 pub oracle_px: Decimal,
809 #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
810 pub premium: Option<Decimal>,
811}
812
813#[derive(Debug, Clone, Deserialize)]
815pub struct SpotAssetCtx {
816 #[serde(flatten)]
817 pub shared: SharedAssetCtx,
818 #[serde(
819 rename = "circulatingSupply",
820 deserialize_with = "deserialize_decimal_from_str"
821 )]
822 pub circulating_supply: Decimal,
823}
824
825#[derive(Debug, Clone, Deserialize)]
827pub struct WsActiveAssetData {
828 pub user: String,
829 pub coin: Ustr,
830 pub leverage: LeverageData,
831 #[serde(rename = "maxTradeSzs")]
832 pub max_trade_szs: [f64; 2],
833 #[serde(rename = "availableToTrade")]
834 pub available_to_trade: [f64; 2],
835}
836
837#[derive(Debug, Clone, Deserialize)]
839pub struct LeverageData {
840 pub value: f64,
841 pub type_: String,
842}
843
844#[derive(Debug, Clone, Deserialize)]
846pub struct WsUserTwapSliceFillsData {
847 #[serde(rename = "isSnapshot")]
848 pub is_snapshot: Option<bool>,
849 pub user: String,
850 #[serde(rename = "twapSliceFills")]
851 pub twap_slice_fills: Vec<WsTwapSliceFillData>,
852}
853
854#[derive(Debug, Clone, Deserialize)]
856pub struct WsTwapSliceFillData {
857 pub fill: WsFillData,
858 #[serde(rename = "twapId")]
859 pub twap_id: u64,
860}
861
862#[derive(Debug, Clone, Deserialize)]
864pub struct WsUserTwapHistoryData {
865 #[serde(rename = "isSnapshot")]
866 pub is_snapshot: Option<bool>,
867 pub user: String,
868 pub history: Vec<WsTwapHistoryData>,
869}
870
871#[derive(Debug, Clone, Deserialize)]
873pub struct WsTwapHistoryData {
874 pub state: TwapStateData,
875 pub status: TwapStatusData,
876 pub time: u64,
877}
878
879#[derive(Debug, Clone, Deserialize)]
881pub struct TwapStateData {
882 pub coin: Ustr,
883 pub user: String,
884 pub side: HyperliquidSide,
885 pub sz: f64,
886 #[serde(rename = "executedSz")]
887 pub executed_sz: f64,
888 #[serde(rename = "executedNtl")]
889 pub executed_ntl: f64,
890 pub minutes: u32,
891 #[serde(rename = "reduceOnly")]
892 pub reduce_only: bool,
893 pub randomize: bool,
894 pub timestamp: u64,
895}
896
897#[derive(Debug, Clone, Deserialize)]
899pub struct TwapStatusData {
900 pub status: HyperliquidTwapStatus,
901 pub description: String,
902}
903
904#[derive(Debug, Clone, Deserialize)]
906pub struct WsBboData {
907 pub coin: Ustr,
908 pub time: u64,
909 pub bbo: [Option<WsLevelData>; 2], }
911
912#[cfg(test)]
913mod tests {
914 use rstest::rstest;
915 use rust_decimal_macros::dec;
916 use serde_json;
917
918 use super::*;
919
920 #[rstest]
921 fn test_subscription_request_serialization() {
922 let sub = SubscriptionRequest::L2Book {
923 coin: Ustr::from("BTC"),
924 n_sig_figs: Some(5),
925 mantissa: None,
926 };
927
928 let json = serde_json::to_string(&sub).unwrap();
929 assert!(json.contains(r#""type":"l2Book""#));
930 assert!(json.contains(r#""coin":"BTC""#));
931 }
932
933 #[rstest]
934 fn test_hyperliquid_ws_request_serialization() {
935 let req = HyperliquidWsRequest::Subscribe {
936 subscription: SubscriptionRequest::Trades {
937 coin: Ustr::from("ETH"),
938 },
939 };
940
941 let json = serde_json::to_string(&req).unwrap();
942 assert!(json.contains(r#""method":"subscribe""#));
943 assert!(json.contains(r#""type":"trades""#));
944 }
945
946 #[rstest]
947 fn test_order_request_serialization() {
948 let order = OrderRequest {
949 a: 0, b: true, p: "50000.0".to_string(),
952 s: "0.1".to_string(),
953 r: false,
954 t: OrderTypeRequest::Limit {
955 tif: TimeInForceRequest::Gtc,
956 },
957 c: Some("client-123".to_string()),
958 };
959
960 let json = serde_json::to_string(&order).unwrap();
961 assert!(json.contains(r#""a":0"#));
962 assert!(json.contains(r#""b":true"#));
963 assert!(json.contains(r#""p":"50000.0""#));
964 }
965
966 #[rstest]
967 fn test_ws_trade_data_deserialization() {
968 let json = r#"{
969 "coin": "BTC",
970 "side": "B",
971 "px": "50000.0",
972 "sz": "0.1",
973 "hash": "0x123",
974 "time": 1234567890,
975 "tid": 12345,
976 "users": ["0xabc", "0xdef"]
977 }"#;
978
979 let trade: WsTradeData = serde_json::from_str(json).unwrap();
980 assert_eq!(trade.coin, "BTC");
981 assert_eq!(trade.side, HyperliquidSide::Buy);
982 assert_eq!(trade.px, dec!(50000.0));
983 }
984
985 #[rstest]
986 fn test_ws_book_data_deserialization() {
987 let json = r#"{
988 "coin": "ETH",
989 "levels": [
990 [{"px": "3000.0", "sz": "1.0", "n": 1}],
991 [{"px": "3001.0", "sz": "2.0", "n": 2}]
992 ],
993 "time": 1234567890
994 }"#;
995
996 let book: WsBookData = serde_json::from_str(json).unwrap();
997 assert_eq!(book.coin, "ETH");
998 assert_eq!(book.levels[0].len(), 1);
999 assert_eq!(book.levels[1].len(), 1);
1000 }
1001
1002 #[rstest]
1003 fn test_ws_trailing_stop_data_deserialization() {
1004 let json = r#"{
1005 "offset": "100.0",
1006 "offsetType": "price",
1007 "callbackPrice": "50000.0"
1008 }"#;
1009
1010 let data: WsTrailingStopData = serde_json::from_str(json).unwrap();
1011 assert_eq!(data.offset, dec!(100.0));
1012 assert_eq!(data.offset_type, TrailingOffsetType::Price);
1013 assert_eq!(data.callback_price.unwrap(), dec!(50000.0));
1014 }
1015
1016 #[rstest]
1017 fn test_ws_trigger_activated_data_deserialization() {
1018 let json = r#"{
1019 "coin": "BTC",
1020 "oid": 12345,
1021 "time": 1704470400000,
1022 "triggerPx": "50000.0",
1023 "tpsl": "sl"
1024 }"#;
1025
1026 let data: WsTriggerActivatedData = serde_json::from_str(json).unwrap();
1027 assert_eq!(data.coin, Ustr::from("BTC"));
1028 assert_eq!(data.oid, 12345);
1029 assert_eq!(data.trigger_px, dec!(50000.0));
1030 assert_eq!(data.tpsl, HyperliquidTpSl::Sl);
1031 assert_eq!(data.time, 1704470400000);
1032 }
1033
1034 #[rstest]
1035 fn test_ws_trigger_triggered_data_deserialization() {
1036 let json = r#"{
1037 "coin": "ETH",
1038 "oid": 67890,
1039 "time": 1704470500000,
1040 "triggerPx": "3000.0",
1041 "marketPx": "3001.0",
1042 "tpsl": "tp",
1043 "resultingOid": 99999
1044 }"#;
1045
1046 let data: WsTriggerTriggeredData = serde_json::from_str(json).unwrap();
1047 assert_eq!(data.coin, Ustr::from("ETH"));
1048 assert_eq!(data.oid, 67890);
1049 assert_eq!(data.trigger_px, dec!(3000.0));
1050 assert_eq!(data.market_px, dec!(3001.0));
1051 assert_eq!(data.tpsl, HyperliquidTpSl::Tp);
1052 assert_eq!(data.resulting_oid, Some(99999));
1053 }
1054
1055 #[rstest]
1056 fn test_ws_fill_data_deserialization_with_cloid_and_twap() {
1057 let json = r#"{
1058 "coin": "@107",
1059 "px": "31.737",
1060 "sz": "0.31",
1061 "side": "B",
1062 "time": 1769920606068,
1063 "startPosition": "0.0",
1064 "dir": "Buy",
1065 "closedPnl": "0.0",
1066 "hash": "0xc731e7561e5334a0c8ab043472ce7d01d400ff3bb95653726afa92a8dd570e8b",
1067 "oid": 308086083674,
1068 "crossed": true,
1069 "fee": "0.00021699",
1070 "tid": 812806034449156,
1071 "cloid": "0xd211f1c27288259290850338d22132a0",
1072 "feeToken": "HYPE",
1073 "twapId": null
1074 }"#;
1075
1076 let fill: WsFillData = serde_json::from_str(json).unwrap();
1077 assert_eq!(fill.coin, "@107");
1078 assert_eq!(fill.px, dec!(31.737));
1079 assert_eq!(fill.sz, dec!(0.31));
1080 assert_eq!(fill.side, HyperliquidSide::Buy);
1081 assert_eq!(fill.oid, 308086083674);
1082 assert!(fill.crossed);
1083 assert_eq!(fill.fee, dec!(0.00021699));
1084 assert_eq!(fill.fee_token, "HYPE");
1085 assert_eq!(
1086 fill.cloid,
1087 Some("0xd211f1c27288259290850338d22132a0".to_string())
1088 );
1089 assert!(fill.twap_id.is_none() || fill.twap_id == Some(serde_json::Value::Null));
1090 }
1091
1092 #[rstest]
1093 fn test_ws_user_fills_message_deserialization() {
1094 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}]}}"#;
1095
1096 let msg: HyperliquidWsMessage = serde_json::from_str(json).unwrap();
1097
1098 match msg {
1099 HyperliquidWsMessage::User { data } => match data {
1100 WsUserEventData::Fills { fills } => {
1101 assert_eq!(fills.len(), 1);
1102 let fill = &fills[0];
1103 assert_eq!(fill.coin, "@107");
1104 assert_eq!(fill.px, dec!(31.737));
1105 assert_eq!(
1106 fill.cloid,
1107 Some("0xd211f1c27288259290850338d22132a0".to_string())
1108 );
1109 }
1110 _ => panic!("Expected Fills variant"),
1111 },
1112 _ => panic!("Expected User channel message"),
1113 }
1114 }
1115
1116 #[rstest]
1117 fn test_ws_user_fills_message_with_builder_fee() {
1118 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}]}}"#;
1120
1121 let msg: HyperliquidWsMessage = serde_json::from_str(json).unwrap();
1122
1123 match msg {
1124 HyperliquidWsMessage::User { data } => match data {
1125 WsUserEventData::Fills { fills } => {
1126 assert_eq!(fills.len(), 1);
1127 let fill = &fills[0];
1128 assert_eq!(fill.coin, "BTC");
1129 assert_eq!(fill.px, dec!(79146.0));
1130 assert_eq!(fill.side, HyperliquidSide::Sell);
1131 assert_eq!(fill.builder_fee, Some(dec!(0.007914)));
1132 assert_eq!(fill.fee_token, "USDC");
1133 }
1134 _ => panic!("Expected Fills variant"),
1135 },
1136 _ => panic!("Expected User channel message"),
1137 }
1138 }
1139
1140 #[rstest]
1141 fn test_ws_user_fills_message_with_liquidation() {
1142 let json = include_str!("../../test_data/ws_user_fill_liquidation.json");
1145
1146 let msg: HyperliquidWsMessage = serde_json::from_str(json).unwrap();
1147
1148 match msg {
1149 HyperliquidWsMessage::User { data } => match data {
1150 WsUserEventData::Fills { fills } => {
1151 assert_eq!(fills.len(), 1);
1152 let fill = &fills[0];
1153 let liquidation = fill.liquidation.as_ref().expect("expected liquidation");
1154 assert_eq!(fill.coin, "BTC");
1155 assert_eq!(fill.side, HyperliquidSide::Sell);
1156 assert_eq!(liquidation.mark_px, dec!(66607.0));
1157 assert_eq!(liquidation.method, HyperliquidLiquidationMethod::Market);
1158 assert_eq!(
1159 liquidation.liquidated_user.as_deref(),
1160 Some("0x360878d351f05975e25f1807a27895e1e5e004fb"),
1161 );
1162 }
1163 _ => panic!("Expected Fills variant"),
1164 },
1165 _ => panic!("Expected User channel message"),
1166 }
1167 }
1168
1169 #[rstest]
1170 fn test_ws_trade_data_round_trips_decimals_as_strings() {
1171 let json = r#"{"coin":"BTC","side":"B","px":"66653.0","sz":"0.001","hash":"0xabc","time":1,"tid":2,"users":["0xa","0xb"]}"#;
1174
1175 let trade: WsTradeData = serde_json::from_str(json).unwrap();
1176 assert_eq!(trade.px, dec!(66653.0));
1177 assert_eq!(trade.sz, dec!(0.001));
1178
1179 let value = serde_json::to_value(&trade).unwrap();
1180 assert_eq!(value["px"], serde_json::Value::from("66653.0"));
1181 assert_eq!(value["sz"], serde_json::Value::from("0.001"));
1182 }
1183}
1184
1185#[derive(Debug, Clone)]
1192pub enum NautilusWsMessage {
1193 ExecutionReports(Vec<ExecutionReport>),
1195 Trades(Vec<TradeTick>),
1197 Quote(QuoteTick),
1199 Deltas(OrderBookDeltas),
1201 Depth10(Box<OrderBookDepth10>),
1203 Candle(Bar),
1205 MarkPrice(MarkPriceUpdate),
1207 IndexPrice(IndexPriceUpdate),
1209 FundingRate(FundingRateUpdate),
1211 CustomData(Data),
1213 Error(String),
1215 Reconnected,
1217}
1218
1219#[derive(Debug, Clone)]
1224#[allow(
1225 clippy::large_enum_variant,
1226 reason = "the variant size gap only crosses the threshold when high-precision widens the raw types"
1227)]
1228pub enum ExecutionReport {
1229 Order(OrderStatusReport),
1231 Fill(FillReport),
1233}