Skip to main content

nautilus_hyperliquid/
data_types.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
16//! Hyperliquid-specific custom data types.
17//!
18//! These types carry Hyperliquid domain data through the Nautilus data engine as
19//! [`CustomData`](nautilus_model::data::CustomData).
20
21use std::collections::HashMap;
22
23use nautilus_core::UnixNanos;
24use nautilus_model::{
25    custom_data,
26    enums::{AggressorSide, OrderSide},
27    identifiers::InstrumentId,
28    types::{Price, Quantity},
29};
30#[cfg(feature = "arrow")]
31use nautilus_serialization::arrow_custom_data;
32use rust_decimal::Decimal;
33use serde::{Deserialize, Serialize};
34
35use crate::common::enums::HyperliquidTwapStatus;
36
37/// Hyperliquid all mid prices snapshot from the `allMids` WebSocket channel.
38#[cfg_attr(
39    feature = "arrow",
40    arrow_custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")
41)]
42#[custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")]
43pub struct HyperliquidAllMids {
44    /// Mapping of instrument ID to mid price for all tradable coins.
45    #[custom_data_field(serde)]
46    pub mids: HashMap<InstrumentId, Price>,
47    /// UNIX timestamp (nanoseconds) when the data event occurred.
48    pub ts_event: UnixNanos,
49    /// UNIX timestamp (nanoseconds) when the instance was initialized.
50    pub ts_init: UnixNanos,
51}
52
53/// Hyperliquid open interest update from the `activeAssetCtx` WebSocket channel.
54///
55/// Hyperliquid does not provide a native event timestamp on this payload, so
56/// `ts_event` mirrors `ts_init` like the peer asset-context update types.
57#[cfg_attr(
58    feature = "arrow",
59    arrow_custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")
60)]
61#[custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")]
62pub struct HyperliquidOpenInterest {
63    /// The instrument ID for this open interest update.
64    pub instrument_id: InstrumentId,
65    /// The current open interest for the perpetual instrument.
66    pub open_interest: Decimal,
67    /// UNIX timestamp (nanoseconds) when the data event occurred.
68    pub ts_event: UnixNanos,
69    /// UNIX timestamp (nanoseconds) when the instance was initialized.
70    pub ts_init: UnixNanos,
71}
72
73/// A complete public Hyperliquid trade, including the venue-provided counterparties.
74///
75/// This is opt-in adapter-specific data. It deliberately does not extend the
76/// generic [`TradeTick`](nautilus_model::data::TradeTick), and is self-contained
77/// so one catalog stream can be recorded and replayed without joining sidecar data.
78#[cfg_attr(
79    feature = "arrow",
80    arrow_custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")
81)]
82#[custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")]
83pub struct HyperliquidPublicTrade {
84    /// The instrument ID for this trade.
85    pub instrument_id: InstrumentId,
86    /// The trade price normalized to the instrument's precision.
87    pub price: Price,
88    /// The trade size normalized to the instrument's precision.
89    pub size: Quantity,
90    /// The aggressor side reported by Hyperliquid.
91    #[custom_data_field(native_enum)]
92    pub aggressor_side: AggressorSide,
93    /// Hyperliquid venue trade identifier.
94    pub trade_id: String,
95    /// Buyer wallet address reported by Hyperliquid.
96    pub buyer: String,
97    /// Seller wallet address reported by Hyperliquid.
98    pub seller: String,
99    /// Hyperliquid trade hash.
100    pub hash: String,
101    /// UNIX timestamp (nanoseconds) when the trade occurred.
102    pub ts_event: UnixNanos,
103    /// UNIX timestamp (nanoseconds) when the instance was initialized.
104    pub ts_init: UnixNanos,
105}
106
107/// Impact prices reported by Hyperliquid for venue-side execution estimates.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109pub struct HyperliquidImpactPrices {
110    /// Impact bid price.
111    pub bid: Price,
112    /// Impact ask price.
113    pub ask: Price,
114}
115
116/// Normalized per-instrument entry within `allDexsAssetCtxs`.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct HyperliquidDexAssetCtx {
119    /// Hyperliquid dex identifier. The default perp dex uses the empty string.
120    pub dex: String,
121    /// Canonical Nautilus instrument ID.
122    pub instrument_id: InstrumentId,
123    /// Mark price.
124    pub mark_price: Price,
125    /// Oracle/index price.
126    pub oracle_price: Price,
127    /// Previous day price.
128    pub prev_day_price: Price,
129    /// Optional mid price.
130    pub mid_price: Option<Price>,
131    /// Optional impact prices.
132    pub impact_prices: Option<HyperliquidImpactPrices>,
133    /// Current funding rate.
134    pub funding_rate: Decimal,
135    /// Current open interest.
136    pub open_interest: Decimal,
137    /// Optional premium.
138    pub premium: Option<Decimal>,
139    /// 24h notional volume.
140    pub day_ntl_volume: Decimal,
141    /// 24h base volume.
142    pub day_base_volume: Decimal,
143}
144
145/// Hyperliquid normalized aggregate snapshot from the `allDexsAssetCtxs` WebSocket channel.
146///
147/// This feed is live-only and intentionally JSON-backed; it is not coupled to Arrow persistence.
148#[custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")]
149pub struct HyperliquidAllDexsAssetCtxs {
150    /// Normalized per-instrument entries across all perp dexes.
151    #[custom_data_field(serde)]
152    pub entries: Vec<HyperliquidDexAssetCtx>,
153    /// UNIX timestamp (nanoseconds) when the data event occurred.
154    pub ts_event: UnixNanos,
155    /// UNIX timestamp (nanoseconds) when the instance was initialized.
156    pub ts_init: UnixNanos,
157}
158
159/// One history row from the Hyperliquid `userTwapHistory` WebSocket channel.
160///
161/// Opt-in custom data (not part of default user execution subscriptions).
162#[cfg_attr(
163    feature = "arrow",
164    arrow_custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")
165)]
166#[custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")]
167pub struct HyperliquidTwapHistory {
168    /// User address from the subscription envelope.
169    pub user: String,
170    /// Venue TWAP id (`twapId`) when present on the row.
171    #[custom_data_field(serde)]
172    pub twap_id: Option<u64>,
173    /// Raw Hyperliquid coin symbol from `state.coin`.
174    pub coin: String,
175    /// Resolved Nautilus instrument ID when the coin is known in cache.
176    #[custom_data_field(serde)]
177    pub instrument_id: Option<InstrumentId>,
178    /// TWAP order side.
179    #[custom_data_field(serde)]
180    pub side: OrderSide,
181    /// Total TWAP size.
182    #[custom_data_field(serde)]
183    pub size: Decimal,
184    /// Executed size so far.
185    #[custom_data_field(serde)]
186    pub executed_size: Decimal,
187    /// Executed notional so far.
188    #[custom_data_field(serde)]
189    pub executed_notional: Decimal,
190    /// TWAP duration in minutes.
191    pub minutes: u32,
192    /// Whether the TWAP is reduce-only.
193    pub reduce_only: bool,
194    /// Whether slice timing is randomized.
195    pub randomize: bool,
196    /// Venue TWAP status.
197    #[custom_data_field(serde)]
198    pub status: HyperliquidTwapStatus,
199    /// Venue status description.
200    pub status_description: String,
201    /// `state.timestamp` converted to UNIX nanoseconds.
202    pub state_timestamp: UnixNanos,
203    /// Whether this event belongs to a venue snapshot batch.
204    pub is_snapshot: bool,
205    /// UNIX timestamp (nanoseconds) when the history row was produced (`history.time`).
206    pub ts_event: UnixNanos,
207    /// UNIX timestamp (nanoseconds) when the instance was initialized.
208    pub ts_init: UnixNanos,
209}
210
211/// One slice fill from the Hyperliquid `userTwapSliceFills` WebSocket channel.
212///
213/// Opt-in custom data (not part of default user execution subscriptions).
214#[cfg_attr(
215    feature = "arrow",
216    arrow_custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")
217)]
218#[custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")]
219pub struct HyperliquidTwapSliceFill {
220    /// User address from the subscription envelope.
221    pub user: String,
222    /// Venue TWAP order identifier.
223    pub twap_id: u64,
224    /// Raw Hyperliquid coin symbol from the fill.
225    pub coin: String,
226    /// Resolved Nautilus instrument ID when the coin is known in cache.
227    #[custom_data_field(serde)]
228    pub instrument_id: Option<InstrumentId>,
229    /// Fill price.
230    #[custom_data_field(serde)]
231    pub price: Decimal,
232    /// Fill size.
233    #[custom_data_field(serde)]
234    pub size: Decimal,
235    /// Fill side.
236    #[custom_data_field(serde)]
237    pub side: OrderSide,
238    /// L1 transaction hash.
239    pub hash: String,
240    /// Venue order id for the slice.
241    pub oid: u64,
242    /// Venue trade id.
243    pub tid: u64,
244    /// Whether the fill crossed the spread (taker).
245    pub crossed: bool,
246    /// Fee amount (negative means rebate).
247    #[custom_data_field(serde)]
248    pub fee: Decimal,
249    /// Token the fee was paid in.
250    pub fee_token: String,
251    /// Frontend display direction string from the venue.
252    pub dir: String,
253    /// Closed PnL for the fill.
254    #[custom_data_field(serde)]
255    pub closed_pnl: Decimal,
256    /// Whether this event belongs to a venue snapshot batch.
257    pub is_snapshot: bool,
258    /// UNIX timestamp (nanoseconds) when the fill occurred.
259    pub ts_event: UnixNanos,
260    /// UNIX timestamp (nanoseconds) when the instance was initialized.
261    pub ts_init: UnixNanos,
262}
263
264/// Registers Hyperliquid custom data types.
265///
266/// Safe to call multiple times (idempotent via internal `Once` guards).
267pub fn register_hyperliquid_custom_data() {
268    #[cfg(feature = "arrow")]
269    {
270        nautilus_serialization::ensure_custom_data_registered::<HyperliquidAllMids>();
271        nautilus_serialization::ensure_custom_data_registered::<HyperliquidOpenInterest>();
272        nautilus_serialization::ensure_custom_data_registered::<HyperliquidPublicTrade>();
273        nautilus_serialization::ensure_custom_data_registered::<HyperliquidTwapHistory>();
274        nautilus_serialization::ensure_custom_data_registered::<HyperliquidTwapSliceFill>();
275    }
276
277    #[cfg(not(feature = "arrow"))]
278    {
279        let _ = nautilus_model::data::ensure_custom_data_json_registered::<HyperliquidAllMids>();
280        let _ =
281            nautilus_model::data::ensure_custom_data_json_registered::<HyperliquidOpenInterest>();
282        let _ =
283            nautilus_model::data::ensure_custom_data_json_registered::<HyperliquidPublicTrade>();
284        let _ =
285            nautilus_model::data::ensure_custom_data_json_registered::<HyperliquidTwapHistory>();
286        let _ =
287            nautilus_model::data::ensure_custom_data_json_registered::<HyperliquidTwapSliceFill>();
288    }
289
290    let _ =
291        nautilus_model::data::ensure_custom_data_json_registered::<HyperliquidAllDexsAssetCtxs>();
292}
293
294#[cfg(test)]
295mod tests {
296    use rstest::rstest;
297
298    use super::*;
299
300    #[rstest]
301    fn test_register_hyperliquid_custom_data_is_idempotent() {
302        register_hyperliquid_custom_data();
303        register_hyperliquid_custom_data();
304    }
305
306    #[cfg(feature = "arrow")]
307    #[rstest]
308    fn test_hyperliquid_all_mids_arrow_schema() {
309        use arrow::datatypes::DataType;
310        use nautilus_serialization::arrow::ArrowSchemaProvider;
311
312        let schema = HyperliquidAllMids::get_schema(None);
313
314        assert_eq!(schema.fields().len(), 3);
315        assert_eq!(schema.field(0).name(), "mids");
316        assert_eq!(schema.field(0).data_type(), &DataType::Utf8);
317        assert_eq!(schema.field(1).name(), "ts_event");
318        assert_eq!(
319            schema.field(1).data_type(),
320            &nautilus_serialization::arrow::timestamp_data_type(),
321        );
322        assert_eq!(schema.field(2).name(), "ts_init");
323        assert_eq!(
324            schema.field(2).data_type(),
325            &nautilus_serialization::arrow::timestamp_data_type(),
326        );
327    }
328
329    #[cfg(feature = "arrow")]
330    #[rstest]
331    fn test_hyperliquid_open_interest_arrow_schema() {
332        use arrow::datatypes::DataType;
333        use nautilus_serialization::arrow::ArrowSchemaProvider;
334
335        let schema = HyperliquidOpenInterest::get_schema(None);
336
337        assert_eq!(schema.fields().len(), 4);
338        assert_eq!(schema.field(0).name(), "instrument_id");
339        assert!(matches!(
340            schema.field(0).data_type(),
341            DataType::Utf8 | DataType::Utf8View
342        ));
343        assert_eq!(schema.field(1).name(), "open_interest");
344        assert_eq!(schema.field(1).data_type(), &DataType::Decimal128(38, 16));
345        assert_eq!(schema.field(2).name(), "ts_event");
346        assert_eq!(
347            schema.field(2).data_type(),
348            &nautilus_serialization::arrow::timestamp_data_type(),
349        );
350        assert_eq!(schema.field(3).name(), "ts_init");
351        assert_eq!(
352            schema.field(3).data_type(),
353            &nautilus_serialization::arrow::timestamp_data_type(),
354        );
355    }
356
357    #[cfg(feature = "arrow")]
358    #[rstest]
359    fn test_hyperliquid_open_interest_arrow_round_trip_preserves_decimal() {
360        use std::str::FromStr;
361
362        use nautilus_model::data::Data;
363        use nautilus_serialization::arrow::{DecodeDataFromRecordBatch, EncodeToRecordBatch};
364
365        let original = HyperliquidOpenInterest::new(
366            InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
367            Decimal::from_str("123456.789012345678").unwrap(),
368            UnixNanos::from(1),
369            UnixNanos::from(2),
370        );
371        let metadata = EncodeToRecordBatch::metadata(&original);
372        let batch =
373            HyperliquidOpenInterest::encode_batch(&metadata, std::slice::from_ref(&original))
374                .unwrap();
375        let decoded = HyperliquidOpenInterest::decode_data_batch(&metadata, batch).unwrap();
376
377        assert_eq!(decoded.len(), 1);
378        match &decoded[0] {
379            Data::Custom(custom) => {
380                let open_interest = custom
381                    .data
382                    .as_any()
383                    .downcast_ref::<HyperliquidOpenInterest>()
384                    .expect("expected HyperliquidOpenInterest");
385                assert_eq!(open_interest.instrument_id, original.instrument_id);
386                assert_eq!(open_interest.open_interest, original.open_interest);
387                assert_eq!(open_interest.ts_event, original.ts_event);
388                assert_eq!(open_interest.ts_init, original.ts_init);
389            }
390            other => panic!("Expected Data::Custom, was {other:?}"),
391        }
392    }
393
394    #[cfg(feature = "arrow")]
395    #[rstest]
396    fn test_hyperliquid_public_trade_arrow_round_trip_preserves_counterparties() {
397        use nautilus_model::{
398            data::Data,
399            enums::AggressorSide,
400            types::{Price, Quantity},
401        };
402        use nautilus_serialization::arrow::{DecodeDataFromRecordBatch, EncodeToRecordBatch};
403
404        let original = HyperliquidPublicTrade::new(
405            InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
406            Price::from("100000.50"),
407            Quantity::from("0.123"),
408            AggressorSide::Buy,
409            "123456".to_string(),
410            "0xbuyer".to_string(),
411            "0xseller".to_string(),
412            "0xhash".to_string(),
413            UnixNanos::from(1),
414            UnixNanos::from(2),
415        );
416        let metadata = EncodeToRecordBatch::metadata(&original);
417        let batch =
418            HyperliquidPublicTrade::encode_batch(&metadata, std::slice::from_ref(&original))
419                .unwrap();
420        assert_eq!(
421            batch
422                .schema()
423                .field_with_name("aggressor_side")
424                .unwrap()
425                .data_type(),
426            &nautilus_serialization::arrow::enum_dictionary_data_type(),
427        );
428        let decoded = HyperliquidPublicTrade::decode_data_batch(&metadata, batch).unwrap();
429
430        let Data::Custom(custom) = &decoded[0] else {
431            panic!("Expected Data::Custom");
432        };
433        let trade = custom
434            .data
435            .as_any()
436            .downcast_ref::<HyperliquidPublicTrade>()
437            .expect("expected HyperliquidPublicTrade");
438        assert_eq!(trade.buyer, original.buyer);
439        assert_eq!(trade.seller, original.seller);
440        assert_eq!(trade.hash, original.hash);
441        assert_eq!(trade.price, original.price);
442        assert_eq!(trade.size, original.size);
443        assert_eq!(trade.aggressor_side, original.aggressor_side);
444    }
445
446    #[cfg(feature = "arrow")]
447    #[rstest]
448    fn test_hyperliquid_twap_history_arrow_schema() {
449        use arrow::datatypes::DataType;
450        use nautilus_serialization::arrow::ArrowSchemaProvider;
451
452        let schema = HyperliquidTwapHistory::get_schema(None);
453        let names: Vec<_> = schema.fields().iter().map(|f| f.name().as_str()).collect();
454
455        // Serde-backed fields encode as non-null Utf8 JSON.
456        assert!(names.contains(&"user"));
457        assert!(names.contains(&"twap_id"));
458        assert!(names.contains(&"instrument_id"));
459        assert!(names.contains(&"size"));
460        assert!(names.contains(&"executed_notional"));
461        assert!(names.contains(&"status"));
462        assert!(names.contains(&"is_snapshot"));
463        assert!(names.contains(&"ts_event"));
464        assert!(names.contains(&"ts_init"));
465
466        for name in ["twap_id", "size", "status"] {
467            assert!(matches!(
468                schema.field_with_name(name).unwrap().data_type(),
469                DataType::Utf8 | DataType::Utf8View
470            ));
471        }
472        assert_eq!(
473            schema.field_with_name("ts_init").unwrap().data_type(),
474            &nautilus_serialization::arrow::timestamp_data_type()
475        );
476    }
477
478    #[cfg(feature = "arrow")]
479    #[rstest]
480    fn test_hyperliquid_twap_history_arrow_round_trip_preserves_fields() {
481        use std::str::FromStr;
482
483        use nautilus_model::{data::Data, enums::OrderSide};
484        use nautilus_serialization::arrow::{DecodeDataFromRecordBatch, EncodeToRecordBatch};
485
486        let with_options = HyperliquidTwapHistory::new(
487            "0xuser".to_string(),
488            Some(7),
489            "BTC".to_string(),
490            Some(InstrumentId::from("BTC-USD-PERP.HYPERLIQUID")),
491            OrderSide::Buy,
492            Decimal::from_str("1.25").unwrap(),
493            Decimal::from_str("0.5").unwrap(),
494            Decimal::from_str("50000.123456789").unwrap(),
495            15,
496            false,
497            true,
498            HyperliquidTwapStatus::Finished,
499            "finished".to_string(),
500            UnixNanos::from(10),
501            true,
502            UnixNanos::from(20),
503            UnixNanos::from(30),
504        );
505        let without_options = HyperliquidTwapHistory::new(
506            "0xuser2".to_string(),
507            None,
508            "UNK".to_string(),
509            None,
510            OrderSide::Sell,
511            Decimal::from_str("2").unwrap(),
512            Decimal::ZERO,
513            Decimal::ZERO,
514            60,
515            true,
516            false,
517            HyperliquidTwapStatus::Activated,
518            "activated".to_string(),
519            UnixNanos::from(40),
520            false,
521            UnixNanos::from(50),
522            UnixNanos::from(60),
523        );
524
525        for original in [&with_options, &without_options] {
526            let metadata = EncodeToRecordBatch::metadata(original);
527            let batch =
528                HyperliquidTwapHistory::encode_batch(&metadata, std::slice::from_ref(original))
529                    .unwrap();
530            let decoded = HyperliquidTwapHistory::decode_data_batch(&metadata, batch).unwrap();
531
532            let Data::Custom(custom) = &decoded[0] else {
533                panic!("Expected Data::Custom");
534            };
535            let history = custom
536                .data
537                .as_any()
538                .downcast_ref::<HyperliquidTwapHistory>()
539                .expect("expected HyperliquidTwapHistory");
540            assert_eq!(history, original);
541        }
542    }
543
544    #[cfg(feature = "arrow")]
545    #[rstest]
546    fn test_hyperliquid_twap_slice_fill_arrow_round_trip_preserves_decimals() {
547        use std::str::FromStr;
548
549        use nautilus_model::{data::Data, enums::OrderSide};
550        use nautilus_serialization::arrow::{DecodeDataFromRecordBatch, EncodeToRecordBatch};
551
552        let original = HyperliquidTwapSliceFill::new(
553            "0xslice".to_string(),
554            99,
555            "ETH".to_string(),
556            Some(InstrumentId::from("ETH-USD-PERP.HYPERLIQUID")),
557            Decimal::from_str("3456.789012345678").unwrap(),
558            Decimal::from_str("0.001").unwrap(),
559            OrderSide::Buy,
560            "0xhash".to_string(),
561            111,
562            222,
563            true,
564            Decimal::from_str("-0.0001").unwrap(),
565            "USDC".to_string(),
566            "Open Long".to_string(),
567            Decimal::from_str("1.23").unwrap(),
568            false,
569            UnixNanos::from(1),
570            UnixNanos::from(2),
571        );
572        let metadata = EncodeToRecordBatch::metadata(&original);
573        let batch =
574            HyperliquidTwapSliceFill::encode_batch(&metadata, std::slice::from_ref(&original))
575                .unwrap();
576        let decoded = HyperliquidTwapSliceFill::decode_data_batch(&metadata, batch).unwrap();
577
578        let Data::Custom(custom) = &decoded[0] else {
579            panic!("Expected Data::Custom");
580        };
581        let fill = custom
582            .data
583            .as_any()
584            .downcast_ref::<HyperliquidTwapSliceFill>()
585            .expect("expected HyperliquidTwapSliceFill");
586        assert_eq!(fill, &original);
587        assert_eq!(fill.price, original.price);
588        assert_eq!(fill.fee, original.fee);
589        assert_eq!(fill.closed_pnl, original.closed_pnl);
590    }
591}