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,
26    identifiers::InstrumentId,
27    types::{Price, Quantity},
28};
29use nautilus_persistence_macros::custom_data;
30use rust_decimal::Decimal;
31use serde::{Deserialize, Serialize};
32
33/// Hyperliquid all mid prices snapshot from the `allMids` WebSocket channel.
34#[cfg_attr(
35    feature = "arrow",
36    custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")
37)]
38#[cfg_attr(
39    not(feature = "arrow"),
40    custom_data(pyo3, no_arrow, stub_module = "nautilus_trader.adapters.hyperliquid")
41)]
42pub struct HyperliquidAllMids {
43    /// Mapping of instrument ID to mid price for all tradable coins.
44    #[custom_data_field(serde)]
45    pub mids: HashMap<InstrumentId, Price>,
46    /// UNIX timestamp (nanoseconds) when the data event occurred.
47    pub ts_event: UnixNanos,
48    /// UNIX timestamp (nanoseconds) when the instance was initialized.
49    pub ts_init: UnixNanos,
50}
51
52/// Hyperliquid open interest update from the `activeAssetCtx` WebSocket channel.
53///
54/// Hyperliquid does not provide a native event timestamp on this payload, so
55/// `ts_event` mirrors `ts_init` like the peer asset-context update types.
56#[cfg_attr(
57    feature = "arrow",
58    custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")
59)]
60#[cfg_attr(
61    not(feature = "arrow"),
62    custom_data(pyo3, no_arrow, stub_module = "nautilus_trader.adapters.hyperliquid")
63)]
64pub struct HyperliquidOpenInterest {
65    /// The instrument ID for this open interest update.
66    pub instrument_id: InstrumentId,
67    /// The current open interest for the perpetual instrument.
68    #[custom_data_field(serde)]
69    pub open_interest: Decimal,
70    /// UNIX timestamp (nanoseconds) when the data event occurred.
71    pub ts_event: UnixNanos,
72    /// UNIX timestamp (nanoseconds) when the instance was initialized.
73    pub ts_init: UnixNanos,
74}
75
76/// A complete public Hyperliquid trade, including the venue-provided counterparties.
77///
78/// This is opt-in adapter-specific data. It deliberately does not extend the
79/// generic [`TradeTick`](nautilus_model::data::TradeTick), and is self-contained
80/// so one catalog stream can be recorded and replayed without joining sidecar data.
81#[cfg_attr(
82    feature = "arrow",
83    custom_data(pyo3, stub_module = "nautilus_trader.adapters.hyperliquid")
84)]
85#[cfg_attr(
86    not(feature = "arrow"),
87    custom_data(pyo3, no_arrow, stub_module = "nautilus_trader.adapters.hyperliquid")
88)]
89pub struct HyperliquidPublicTrade {
90    /// The instrument ID for this trade.
91    pub instrument_id: InstrumentId,
92    /// The trade price normalized to the instrument's precision.
93    #[custom_data_field(serde)]
94    pub price: Price,
95    /// The trade size normalized to the instrument's precision.
96    #[custom_data_field(serde)]
97    pub size: Quantity,
98    /// The aggressor side reported by Hyperliquid.
99    #[custom_data_field(serde)]
100    pub aggressor_side: AggressorSide,
101    /// Hyperliquid venue trade identifier.
102    pub trade_id: String,
103    /// Buyer wallet address reported by Hyperliquid.
104    pub buyer: String,
105    /// Seller wallet address reported by Hyperliquid.
106    pub seller: String,
107    /// Hyperliquid trade hash.
108    pub hash: String,
109    /// UNIX timestamp (nanoseconds) when the trade occurred.
110    pub ts_event: UnixNanos,
111    /// UNIX timestamp (nanoseconds) when the instance was initialized.
112    pub ts_init: UnixNanos,
113}
114
115/// Impact prices reported by Hyperliquid for venue-side execution estimates.
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117pub struct HyperliquidImpactPrices {
118    /// Impact bid price.
119    pub bid: Price,
120    /// Impact ask price.
121    pub ask: Price,
122}
123
124/// Normalized per-instrument entry within `allDexsAssetCtxs`.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126pub struct HyperliquidDexAssetCtx {
127    /// Hyperliquid dex identifier. The default perp dex uses the empty string.
128    pub dex: String,
129    /// Canonical Nautilus instrument ID.
130    pub instrument_id: InstrumentId,
131    /// Mark price.
132    pub mark_price: Price,
133    /// Oracle/index price.
134    pub oracle_price: Price,
135    /// Previous day price.
136    pub prev_day_price: Price,
137    /// Optional mid price.
138    pub mid_price: Option<Price>,
139    /// Optional impact prices.
140    pub impact_prices: Option<HyperliquidImpactPrices>,
141    /// Current funding rate.
142    pub funding_rate: Decimal,
143    /// Current open interest.
144    pub open_interest: Decimal,
145    /// Optional premium.
146    pub premium: Option<Decimal>,
147    /// 24h notional volume.
148    pub day_ntl_volume: Decimal,
149    /// 24h base volume.
150    pub day_base_volume: Decimal,
151}
152
153/// Hyperliquid normalized aggregate snapshot from the `allDexsAssetCtxs` WebSocket channel.
154///
155/// This feed is live-only and intentionally JSON-backed; it is not coupled to Arrow persistence.
156#[custom_data(pyo3, no_arrow, stub_module = "nautilus_trader.adapters.hyperliquid")]
157pub struct HyperliquidAllDexsAssetCtxs {
158    /// Normalized per-instrument entries across all perp dexes.
159    #[custom_data_field(serde)]
160    pub entries: Vec<HyperliquidDexAssetCtx>,
161    /// UNIX timestamp (nanoseconds) when the data event occurred.
162    pub ts_event: UnixNanos,
163    /// UNIX timestamp (nanoseconds) when the instance was initialized.
164    pub ts_init: UnixNanos,
165}
166
167/// Registers Hyperliquid custom data types.
168///
169/// Safe to call multiple times (idempotent via internal `Once` guards).
170pub fn register_hyperliquid_custom_data() {
171    #[cfg(feature = "arrow")]
172    {
173        nautilus_serialization::ensure_custom_data_registered::<HyperliquidAllMids>();
174        nautilus_serialization::ensure_custom_data_registered::<HyperliquidOpenInterest>();
175        nautilus_serialization::ensure_custom_data_registered::<HyperliquidPublicTrade>();
176    }
177
178    #[cfg(not(feature = "arrow"))]
179    {
180        let _ = nautilus_model::data::ensure_custom_data_json_registered::<HyperliquidAllMids>();
181        let _ =
182            nautilus_model::data::ensure_custom_data_json_registered::<HyperliquidOpenInterest>();
183        let _ =
184            nautilus_model::data::ensure_custom_data_json_registered::<HyperliquidPublicTrade>();
185    }
186
187    let _ =
188        nautilus_model::data::ensure_custom_data_json_registered::<HyperliquidAllDexsAssetCtxs>();
189}
190
191#[cfg(test)]
192mod tests {
193    use rstest::rstest;
194
195    use super::*;
196
197    #[rstest]
198    fn test_register_hyperliquid_custom_data_is_idempotent() {
199        register_hyperliquid_custom_data();
200        register_hyperliquid_custom_data();
201    }
202
203    #[cfg(feature = "arrow")]
204    #[rstest]
205    fn test_hyperliquid_all_mids_arrow_schema() {
206        use arrow::datatypes::DataType;
207        use nautilus_serialization::arrow::ArrowSchemaProvider;
208
209        let schema = HyperliquidAllMids::get_schema(None);
210
211        assert_eq!(schema.fields().len(), 3);
212        assert_eq!(schema.field(0).name(), "mids");
213        assert_eq!(schema.field(0).data_type(), &DataType::Utf8);
214        assert_eq!(schema.field(1).name(), "ts_event");
215        assert_eq!(schema.field(1).data_type(), &DataType::UInt64);
216        assert_eq!(schema.field(2).name(), "ts_init");
217        assert_eq!(schema.field(2).data_type(), &DataType::UInt64);
218    }
219
220    #[cfg(feature = "arrow")]
221    #[rstest]
222    fn test_hyperliquid_open_interest_arrow_schema() {
223        use arrow::datatypes::DataType;
224        use nautilus_serialization::arrow::ArrowSchemaProvider;
225
226        let schema = HyperliquidOpenInterest::get_schema(None);
227
228        assert_eq!(schema.fields().len(), 4);
229        assert_eq!(schema.field(0).name(), "instrument_id");
230        assert!(matches!(
231            schema.field(0).data_type(),
232            DataType::Utf8 | DataType::Utf8View
233        ));
234        assert_eq!(schema.field(1).name(), "open_interest");
235        assert!(matches!(
236            schema.field(1).data_type(),
237            DataType::Utf8 | DataType::Utf8View
238        ));
239        assert_eq!(schema.field(2).name(), "ts_event");
240        assert_eq!(schema.field(2).data_type(), &DataType::UInt64);
241        assert_eq!(schema.field(3).name(), "ts_init");
242        assert_eq!(schema.field(3).data_type(), &DataType::UInt64);
243    }
244
245    #[cfg(feature = "arrow")]
246    #[rstest]
247    fn test_hyperliquid_open_interest_arrow_round_trip_preserves_decimal() {
248        use std::str::FromStr;
249
250        use nautilus_model::data::Data;
251        use nautilus_serialization::arrow::{DecodeDataFromRecordBatch, EncodeToRecordBatch};
252
253        let original = HyperliquidOpenInterest::new(
254            InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
255            Decimal::from_str("123456.789012345678").unwrap(),
256            UnixNanos::from(1),
257            UnixNanos::from(2),
258        );
259        let metadata = EncodeToRecordBatch::metadata(&original);
260        let batch =
261            HyperliquidOpenInterest::encode_batch(&metadata, std::slice::from_ref(&original))
262                .unwrap();
263        let decoded = HyperliquidOpenInterest::decode_data_batch(&metadata, batch).unwrap();
264
265        assert_eq!(decoded.len(), 1);
266        match &decoded[0] {
267            Data::Custom(custom) => {
268                let open_interest = custom
269                    .data
270                    .as_any()
271                    .downcast_ref::<HyperliquidOpenInterest>()
272                    .expect("expected HyperliquidOpenInterest");
273                assert_eq!(open_interest.instrument_id, original.instrument_id);
274                assert_eq!(open_interest.open_interest, original.open_interest);
275                assert_eq!(open_interest.ts_event, original.ts_event);
276                assert_eq!(open_interest.ts_init, original.ts_init);
277            }
278            other => panic!("Expected Data::Custom, was {other:?}"),
279        }
280    }
281
282    #[cfg(feature = "arrow")]
283    #[rstest]
284    fn test_hyperliquid_public_trade_arrow_round_trip_preserves_counterparties() {
285        use nautilus_model::{
286            data::Data,
287            enums::AggressorSide,
288            types::{Price, Quantity},
289        };
290        use nautilus_serialization::arrow::{DecodeDataFromRecordBatch, EncodeToRecordBatch};
291
292        let original = HyperliquidPublicTrade::new(
293            InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
294            Price::from("100000.50"),
295            Quantity::from("0.123"),
296            AggressorSide::Buyer,
297            "123456".to_string(),
298            "0xbuyer".to_string(),
299            "0xseller".to_string(),
300            "0xhash".to_string(),
301            UnixNanos::from(1),
302            UnixNanos::from(2),
303        );
304        let metadata = EncodeToRecordBatch::metadata(&original);
305        let batch =
306            HyperliquidPublicTrade::encode_batch(&metadata, std::slice::from_ref(&original))
307                .unwrap();
308        let decoded = HyperliquidPublicTrade::decode_data_batch(&metadata, batch).unwrap();
309
310        let Data::Custom(custom) = &decoded[0] else {
311            panic!("Expected Data::Custom");
312        };
313        let trade = custom
314            .data
315            .as_any()
316            .downcast_ref::<HyperliquidPublicTrade>()
317            .expect("expected HyperliquidPublicTrade");
318        assert_eq!(trade.buyer, original.buyer);
319        assert_eq!(trade.seller, original.seller);
320        assert_eq!(trade.hash, original.hash);
321        assert_eq!(trade.price, original.price);
322        assert_eq!(trade.size, original.size);
323    }
324}