Skip to main content

nautilus_hyperliquid/websocket/
messages.rs

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