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