1use std::{fmt::Display, str::FromStr};
17
18use ahash::AHashMap;
19use nautilus_core::{UUID4, UnixNanos};
20use nautilus_model::{
21 data::{delta::OrderBookDelta, deltas::OrderBookDeltas, order::BookOrder},
22 enums::{AccountType, BookAction, OrderSide, PositionSide, RecordFlag},
23 events::AccountState,
24 identifiers::{AccountId, InstrumentId},
25 reports::PositionStatusReport,
26 types::{AccountBalance, Price, Quantity},
27};
28use rust_decimal::Decimal;
29use ustr::Ustr;
30
31use crate::{
32 common::parse::normalize_order,
33 http::{
34 models::{HyperliquidL2Book, HyperliquidLevel},
35 parse::get_currency,
36 },
37 websocket::messages::{WsBookData, WsLevelData},
38};
39
40#[derive(Debug, Clone)]
42pub struct HyperliquidInstrumentInfo {
43 pub instrument_id: InstrumentId,
44 pub price_decimals: u8,
45 pub size_decimals: u8,
46 pub tick_size: Option<Decimal>,
48 pub step_size: Option<Decimal>,
50 pub min_notional: Option<Decimal>,
52}
53
54impl HyperliquidInstrumentInfo {
55 pub fn new(instrument_id: InstrumentId, price_decimals: u8, size_decimals: u8) -> Self {
57 Self {
58 instrument_id,
59 price_decimals,
60 size_decimals,
61 tick_size: None,
62 step_size: None,
63 min_notional: None,
64 }
65 }
66
67 pub fn with_metadata(
69 instrument_id: InstrumentId,
70 price_decimals: u8,
71 size_decimals: u8,
72 tick_size: Decimal,
73 step_size: Decimal,
74 min_notional: Decimal,
75 ) -> Self {
76 Self {
77 instrument_id,
78 price_decimals,
79 size_decimals,
80 tick_size: Some(tick_size),
81 step_size: Some(step_size),
82 min_notional: Some(min_notional),
83 }
84 }
85
86 pub fn with_precision(
88 instrument_id: InstrumentId,
89 price_decimals: u8,
90 size_decimals: u8,
91 ) -> Self {
92 let tick_size = Decimal::new(1, price_decimals as u32);
93 let step_size = Decimal::new(1, size_decimals as u32);
94 Self {
95 instrument_id,
96 price_decimals,
97 size_decimals,
98 tick_size: Some(tick_size),
99 step_size: Some(step_size),
100 min_notional: None,
101 }
102 }
103
104 pub fn default_crypto(instrument_id: InstrumentId) -> Self {
106 Self::with_precision(instrument_id, 2, 5) }
108}
109
110#[derive(Debug, Default)]
112pub struct HyperliquidInstrumentCache {
113 instruments_by_symbol: AHashMap<Ustr, HyperliquidInstrumentInfo>,
114}
115
116impl HyperliquidInstrumentCache {
117 pub fn new() -> Self {
119 Self {
120 instruments_by_symbol: AHashMap::new(),
121 }
122 }
123
124 pub fn insert(&mut self, symbol: &str, info: HyperliquidInstrumentInfo) {
126 self.instruments_by_symbol.insert(Ustr::from(symbol), info);
127 }
128
129 pub fn get(&self, symbol: &str) -> Option<&HyperliquidInstrumentInfo> {
131 self.instruments_by_symbol.get(&Ustr::from(symbol))
132 }
133
134 pub fn get_all(&self) -> Vec<&HyperliquidInstrumentInfo> {
136 self.instruments_by_symbol.values().collect()
137 }
138
139 pub fn contains(&self, symbol: &str) -> bool {
141 self.instruments_by_symbol.contains_key(&Ustr::from(symbol))
142 }
143
144 pub fn len(&self) -> usize {
146 self.instruments_by_symbol.len()
147 }
148
149 pub fn is_empty(&self) -> bool {
151 self.instruments_by_symbol.is_empty()
152 }
153
154 pub fn clear(&mut self) {
156 self.instruments_by_symbol.clear();
157 }
158}
159
160#[derive(Clone, Debug, PartialEq, Eq, Hash)]
162pub enum HyperliquidTradeKey {
163 Id(String),
165 Seq(u64),
167}
168
169#[derive(Debug)]
171pub struct HyperliquidDataConverter {
172 configs: AHashMap<Ustr, HyperliquidInstrumentInfo>,
174}
175
176impl Default for HyperliquidDataConverter {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182impl HyperliquidDataConverter {
183 pub fn new() -> Self {
185 Self {
186 configs: AHashMap::new(),
187 }
188 }
189
190 pub fn normalize_order_for_symbol(
195 &mut self,
196 symbol: &str,
197 price: Decimal,
198 qty: Decimal,
199 ) -> Result<(Decimal, Decimal), String> {
200 let config = self.get_config(&Ustr::from(symbol));
201
202 let tick_size = config.tick_size.unwrap_or_else(|| Decimal::new(1, 2)); let step_size = config.step_size.unwrap_or_else(|| {
205 match config.size_decimals {
207 0 => Decimal::ONE,
208 1 => Decimal::new(1, 1), 2 => Decimal::new(1, 2), 3 => Decimal::new(1, 3), 4 => Decimal::new(1, 4), 5 => Decimal::new(1, 5), _ => Decimal::new(1, 6), }
215 });
216 let min_notional = config.min_notional.unwrap_or_else(|| Decimal::from(10)); normalize_order(
219 price,
220 qty,
221 tick_size,
222 step_size,
223 min_notional,
224 config.price_decimals,
225 config.size_decimals,
226 )
227 }
228
229 pub fn configure_instrument(&mut self, symbol: &str, config: HyperliquidInstrumentInfo) {
231 self.configs.insert(Ustr::from(symbol), config);
232 }
233
234 fn get_config(&self, symbol: &Ustr) -> HyperliquidInstrumentInfo {
236 self.configs.get(symbol).cloned().unwrap_or_else(|| {
237 let instrument_id = InstrumentId::from(format!("{symbol}.HYPER"));
239 HyperliquidInstrumentInfo::default_crypto(instrument_id)
240 })
241 }
242
243 pub fn convert_http_snapshot(
245 &self,
246 data: &HyperliquidL2Book,
247 instrument_id: InstrumentId,
248 ts_init: UnixNanos,
249 ) -> Result<OrderBookDeltas, ConversionError> {
250 let config = self.get_config(&data.coin);
251 let ts_event = UnixNanos::from(data.time * 1_000_000);
252 let mut deltas = Vec::with_capacity(1 + data.levels[0].len() + data.levels[1].len());
253
254 deltas.push(OrderBookDelta::clear(
256 instrument_id,
257 0, ts_event,
259 ts_init,
260 ));
261
262 let mut order_id = 1u64; for level in &data.levels[0] {
266 let (price, size) = parse_level(level, &config)?;
267 if size.is_positive() {
268 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
269 deltas.push(OrderBookDelta::new(
270 instrument_id,
271 BookAction::Add,
272 order,
273 RecordFlag::F_LAST as u8, order_id,
275 ts_event,
276 ts_init,
277 ));
278 order_id += 1;
279 }
280 }
281
282 for level in &data.levels[1] {
284 let (price, size) = parse_level(level, &config)?;
285 if size.is_positive() {
286 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
287 deltas.push(OrderBookDelta::new(
288 instrument_id,
289 BookAction::Add,
290 order,
291 RecordFlag::F_LAST as u8, order_id,
293 ts_event,
294 ts_init,
295 ));
296 order_id += 1;
297 }
298 }
299
300 Ok(OrderBookDeltas::new(instrument_id, deltas))
301 }
302
303 pub fn convert_ws_snapshot(
305 &self,
306 data: &WsBookData,
307 instrument_id: InstrumentId,
308 ts_init: UnixNanos,
309 ) -> Result<OrderBookDeltas, ConversionError> {
310 let config = self.get_config(&data.coin);
311 let ts_event = UnixNanos::from(data.time * 1_000_000);
312 let mut deltas = Vec::with_capacity(1 + data.levels[0].len() + data.levels[1].len());
313
314 deltas.push(OrderBookDelta::clear(
316 instrument_id,
317 0, ts_event,
319 ts_init,
320 ));
321
322 let mut order_id = 1u64; for level in &data.levels[0] {
326 let (price, size) = parse_ws_level(level, &config)?;
327 if size.is_positive() {
328 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
329 deltas.push(OrderBookDelta::new(
330 instrument_id,
331 BookAction::Add,
332 order,
333 RecordFlag::F_LAST as u8,
334 order_id,
335 ts_event,
336 ts_init,
337 ));
338 order_id += 1;
339 }
340 }
341
342 for level in &data.levels[1] {
344 let (price, size) = parse_ws_level(level, &config)?;
345 if size.is_positive() {
346 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
347 deltas.push(OrderBookDelta::new(
348 instrument_id,
349 BookAction::Add,
350 order,
351 RecordFlag::F_LAST as u8,
352 order_id,
353 ts_event,
354 ts_init,
355 ));
356 order_id += 1;
357 }
358 }
359
360 Ok(OrderBookDeltas::new(instrument_id, deltas))
361 }
362
363 #[expect(clippy::too_many_arguments)]
366 pub fn convert_delta_update(
367 &self,
368 instrument_id: InstrumentId,
369 sequence: u64,
370 ts_event: UnixNanos,
371 ts_init: UnixNanos,
372 bid_updates: &[(String, String)], ask_updates: &[(String, String)], bid_removals: &[String], ask_removals: &[String], ) -> Result<OrderBookDeltas, ConversionError> {
377 let config = self.get_config(&instrument_id.symbol.inner());
378 let mut deltas = Vec::new();
379 let mut order_id = sequence * 1000; for price_str in bid_removals {
383 let price = parse_price(price_str, &config)?;
384 let order = BookOrder::new(OrderSide::Buy, price, Quantity::from("0"), order_id);
385 deltas.push(OrderBookDelta::new(
386 instrument_id,
387 BookAction::Delete,
388 order,
389 0, sequence,
391 ts_event,
392 ts_init,
393 ));
394 order_id += 1;
395 }
396
397 for price_str in ask_removals {
399 let price = parse_price(price_str, &config)?;
400 let order = BookOrder::new(OrderSide::Sell, price, Quantity::from("0"), order_id);
401 deltas.push(OrderBookDelta::new(
402 instrument_id,
403 BookAction::Delete,
404 order,
405 0, sequence,
407 ts_event,
408 ts_init,
409 ));
410 order_id += 1;
411 }
412
413 for (price_str, size_str) in bid_updates {
415 let price = parse_price(price_str, &config)?;
416 let size = parse_size(size_str, &config)?;
417
418 if size.is_positive() {
419 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
420 deltas.push(OrderBookDelta::new(
421 instrument_id,
422 BookAction::Update, order,
424 0, sequence,
426 ts_event,
427 ts_init,
428 ));
429 } else {
430 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
432 deltas.push(OrderBookDelta::new(
433 instrument_id,
434 BookAction::Delete,
435 order,
436 0, sequence,
438 ts_event,
439 ts_init,
440 ));
441 }
442 order_id += 1;
443 }
444
445 for (price_str, size_str) in ask_updates {
447 let price = parse_price(price_str, &config)?;
448 let size = parse_size(size_str, &config)?;
449
450 if size.is_positive() {
451 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
452 deltas.push(OrderBookDelta::new(
453 instrument_id,
454 BookAction::Update, order,
456 0, sequence,
458 ts_event,
459 ts_init,
460 ));
461 } else {
462 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
464 deltas.push(OrderBookDelta::new(
465 instrument_id,
466 BookAction::Delete,
467 order,
468 0, sequence,
470 ts_event,
471 ts_init,
472 ));
473 }
474 order_id += 1;
475 }
476
477 Ok(OrderBookDeltas::new(instrument_id, deltas))
478 }
479}
480
481fn parse_level(
483 level: &HyperliquidLevel,
484 _inst_info: &HyperliquidInstrumentInfo,
485) -> Result<(Price, Quantity), ConversionError> {
486 let price = price_from_decimal(level.px)?;
487 let size = size_from_decimal(level.sz)?;
488 Ok((price, size))
489}
490
491fn parse_ws_level(
493 level: &WsLevelData,
494 _config: &HyperliquidInstrumentInfo,
495) -> Result<(Price, Quantity), ConversionError> {
496 let price = price_from_decimal(level.px)?;
497 let size = size_from_decimal(level.sz)?;
498 Ok((price, size))
499}
500
501fn parse_price(
503 price_str: &str,
504 _config: &HyperliquidInstrumentInfo,
505) -> Result<Price, ConversionError> {
506 let decimal = Decimal::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
507 value: price_str.to_string(),
508 })?;
509
510 Price::from_decimal(decimal).map_err(|_| ConversionError::InvalidPrice {
511 value: price_str.to_string(),
512 })
513}
514
515fn parse_size(
517 size_str: &str,
518 _config: &HyperliquidInstrumentInfo,
519) -> Result<Quantity, ConversionError> {
520 let decimal = Decimal::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
521 value: size_str.to_string(),
522 })?;
523
524 Quantity::from_decimal(decimal).map_err(|_| ConversionError::InvalidSize {
525 value: size_str.to_string(),
526 })
527}
528
529fn price_from_decimal(value: Decimal) -> Result<Price, ConversionError> {
531 Price::from_decimal(value).map_err(|_| ConversionError::InvalidPrice {
532 value: value.to_string(),
533 })
534}
535
536fn size_from_decimal(value: Decimal) -> Result<Quantity, ConversionError> {
538 Quantity::from_decimal(value).map_err(|_| ConversionError::InvalidSize {
539 value: value.to_string(),
540 })
541}
542
543#[derive(Debug, Clone, PartialEq, Eq)]
545pub enum ConversionError {
546 InvalidPrice { value: String },
548 InvalidSize { value: String },
550 OrderBookDeltasError(String),
552}
553
554impl From<anyhow::Error> for ConversionError {
555 fn from(err: anyhow::Error) -> Self {
556 Self::OrderBookDeltasError(err.to_string())
557 }
558}
559
560impl Display for ConversionError {
561 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
562 match self {
563 Self::InvalidPrice { value } => write!(f, "Invalid price: {value}"),
564 Self::InvalidSize { value } => write!(f, "Invalid size: {value}"),
565 Self::OrderBookDeltasError(msg) => {
566 write!(f, "OrderBookDeltas error: {msg}")
567 }
568 }
569 }
570}
571
572impl std::error::Error for ConversionError {}
573
574#[derive(Clone, Debug)]
583pub struct HyperliquidPositionData {
584 pub asset: String,
585 pub position: Decimal, pub entry_px: Option<Decimal>,
587 pub unrealized_pnl: Decimal,
588 pub cumulative_funding: Option<Decimal>,
589 pub position_value: Decimal,
590}
591
592impl HyperliquidPositionData {
593 pub fn is_flat(&self) -> bool {
595 self.position.is_zero()
596 }
597
598 pub fn is_long(&self) -> bool {
600 self.position > Decimal::ZERO
601 }
602
603 pub fn is_short(&self) -> bool {
605 self.position < Decimal::ZERO
606 }
607}
608
609#[derive(Clone, Debug)]
617pub struct HyperliquidBalance {
618 pub asset: String,
619 pub total: Decimal,
620 pub available: Decimal,
621 pub sequence: u64,
622 pub ts_event: UnixNanos,
623}
624
625impl HyperliquidBalance {
626 pub fn new(
627 asset: String,
628 total: Decimal,
629 available: Decimal,
630 sequence: u64,
631 ts_event: UnixNanos,
632 ) -> Self {
633 Self {
634 asset,
635 total,
636 available,
637 sequence,
638 ts_event,
639 }
640 }
641
642 pub fn locked(&self) -> Decimal {
644 (self.total - self.available).max(Decimal::ZERO)
645 }
646}
647
648#[derive(Default, Debug)]
656pub struct HyperliquidAccountState {
657 pub balances: AHashMap<String, HyperliquidBalance>,
658 pub last_sequence: u64,
659}
660
661impl HyperliquidAccountState {
662 pub fn new() -> Self {
663 Self::default()
664 }
665
666 pub fn get_balance(&self, asset: &str) -> HyperliquidBalance {
668 self.balances.get(asset).cloned().unwrap_or_else(|| {
669 HyperliquidBalance::new(
670 asset.to_string(),
671 Decimal::ZERO,
672 Decimal::ZERO,
673 0,
674 UnixNanos::default(),
675 )
676 })
677 }
678
679 pub fn account_value(&self) -> Decimal {
683 self.balances.values().map(|balance| balance.total).sum()
684 }
685
686 pub fn to_account_state(
695 &self,
696 account_id: AccountId,
697 ts_event: UnixNanos,
698 ts_init: UnixNanos,
699 ) -> anyhow::Result<AccountState> {
700 let balances: Vec<AccountBalance> = self
702 .balances
703 .values()
704 .map(|balance| {
705 let currency = get_currency(&balance.asset);
707 AccountBalance::from_total_and_free(balance.total, balance.available, currency)
708 .map_err(anyhow::Error::from)
709 })
710 .collect::<anyhow::Result<Vec<_>>>()?;
711
712 let margins = Vec::new();
714
715 let account_type = AccountType::Margin;
716 let is_reported = true;
717 let event_id = UUID4::new();
718
719 Ok(AccountState::new(
720 account_id,
721 account_type,
722 balances,
723 margins,
724 is_reported,
725 event_id,
726 ts_event,
727 ts_init,
728 None, ))
730 }
731}
732
733#[derive(Debug, Clone)]
743pub enum HyperliquidAccountEvent {
744 BalanceSnapshot {
746 balances: Vec<HyperliquidBalance>,
747 sequence: u64,
748 },
749 BalanceDelta { balance: HyperliquidBalance },
751}
752
753impl HyperliquidAccountState {
754 pub fn apply(&mut self, event: HyperliquidAccountEvent) {
756 match event {
757 HyperliquidAccountEvent::BalanceSnapshot { balances, sequence } => {
758 self.balances.clear();
759
760 for balance in balances {
761 self.balances.insert(balance.asset.clone(), balance);
762 }
763
764 self.last_sequence = sequence;
765 }
766 HyperliquidAccountEvent::BalanceDelta { balance } => {
767 let sequence = balance.sequence;
768 let entry = self
769 .balances
770 .entry(balance.asset.clone())
771 .or_insert_with(|| balance.clone());
772
773 if sequence > entry.sequence {
775 *entry = balance;
776 self.last_sequence = self.last_sequence.max(sequence);
777 }
778 }
779 }
780 }
781}
782
783pub fn parse_position_status_report(
793 position_data: &HyperliquidPositionData,
794 account_id: AccountId,
795 instrument_id: InstrumentId,
796 ts_init: UnixNanos,
797) -> anyhow::Result<PositionStatusReport> {
798 let position_side = if position_data.is_flat() {
800 PositionSide::Flat
801 } else if position_data.is_long() {
802 PositionSide::Long
803 } else {
804 PositionSide::Short
805 };
806
807 let quantity = Quantity::from_decimal(position_data.position.abs())?;
809
810 let ts_last = ts_init;
811 let avg_px_open = position_data.entry_px;
812
813 Ok(PositionStatusReport::new(
814 account_id,
815 instrument_id,
816 position_side.as_specified(),
817 quantity,
818 ts_last,
819 ts_init,
820 None, None, avg_px_open,
823 ))
824}
825
826#[cfg(test)]
827#[allow(dead_code)]
828mod tests {
829 use rstest::rstest;
830 use rust_decimal_macros::dec;
831
832 use super::*;
833 use crate::common::testing::load_test_data;
834
835 fn test_instrument_id() -> InstrumentId {
836 InstrumentId::from("BTC.HYPER")
837 }
838
839 fn sample_http_book() -> HyperliquidL2Book {
840 load_test_data("http_l2_book_snapshot.json")
841 }
842
843 fn sample_ws_book() -> WsBookData {
844 load_test_data("ws_book_data.json")
845 }
846
847 #[rstest]
848 fn test_http_snapshot_conversion() {
849 let converter = HyperliquidDataConverter::new();
850 let book_data = sample_http_book();
851 let instrument_id = test_instrument_id();
852 let ts_init = UnixNanos::default();
853
854 let deltas = converter
855 .convert_http_snapshot(&book_data, instrument_id, ts_init)
856 .unwrap();
857
858 assert_eq!(deltas.instrument_id, instrument_id);
859 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
863 assert_eq!(clear_delta.instrument_id, instrument_id);
864 assert_eq!(clear_delta.action, BookAction::Clear);
865 assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
866 assert_eq!(clear_delta.order.price.raw, 0);
867 assert_eq!(clear_delta.order.price.precision, 0);
868 assert_eq!(clear_delta.order.size.raw, 0);
869 assert_eq!(clear_delta.order.size.precision, 0);
870 assert_eq!(clear_delta.order.order_id, 0);
871 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
872 assert_eq!(clear_delta.sequence, 0);
873 assert_eq!(
874 clear_delta.ts_event,
875 UnixNanos::from(book_data.time * 1_000_000)
876 );
877 assert_eq!(clear_delta.ts_init, ts_init);
878
879 let first_bid_delta = &deltas.deltas[1];
881 assert_eq!(first_bid_delta.instrument_id, instrument_id);
882 assert_eq!(first_bid_delta.action, BookAction::Add);
883 assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
884 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
885 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
886 assert_eq!(first_bid_delta.order.order_id, 1);
887 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
888 assert_eq!(first_bid_delta.sequence, 1);
889 assert_eq!(
890 first_bid_delta.ts_event,
891 UnixNanos::from(book_data.time * 1_000_000)
892 );
893 assert_eq!(first_bid_delta.ts_init, ts_init);
894
895 for delta in &deltas.deltas[1..] {
897 assert_eq!(delta.action, BookAction::Add);
898 assert!(delta.order.size.is_positive());
899 }
900 }
901
902 #[rstest]
903 fn test_ws_snapshot_conversion() {
904 let converter = HyperliquidDataConverter::new();
905 let book_data = sample_ws_book();
906 let instrument_id = test_instrument_id();
907 let ts_init = UnixNanos::default();
908
909 let deltas = converter
910 .convert_ws_snapshot(&book_data, instrument_id, ts_init)
911 .unwrap();
912
913 assert_eq!(deltas.instrument_id, instrument_id);
914 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
918 assert_eq!(clear_delta.instrument_id, instrument_id);
919 assert_eq!(clear_delta.action, BookAction::Clear);
920 assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
921 assert_eq!(clear_delta.order.price.raw, 0);
922 assert_eq!(clear_delta.order.price.precision, 0);
923 assert_eq!(clear_delta.order.size.raw, 0);
924 assert_eq!(clear_delta.order.size.precision, 0);
925 assert_eq!(clear_delta.order.order_id, 0);
926 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
927 assert_eq!(clear_delta.sequence, 0);
928 assert_eq!(
929 clear_delta.ts_event,
930 UnixNanos::from(book_data.time * 1_000_000)
931 );
932 assert_eq!(clear_delta.ts_init, ts_init);
933
934 let first_bid_delta = &deltas.deltas[1];
936 assert_eq!(first_bid_delta.instrument_id, instrument_id);
937 assert_eq!(first_bid_delta.action, BookAction::Add);
938 assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
939 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
940 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
941 assert_eq!(first_bid_delta.order.order_id, 1);
942 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
943 assert_eq!(first_bid_delta.sequence, 1);
944 assert_eq!(
945 first_bid_delta.ts_event,
946 UnixNanos::from(book_data.time * 1_000_000)
947 );
948 assert_eq!(first_bid_delta.ts_init, ts_init);
949 }
950
951 #[rstest]
952 fn test_delta_update_conversion() {
953 let converter = HyperliquidDataConverter::new();
954 let instrument_id = test_instrument_id();
955 let ts_event = UnixNanos::default();
956 let ts_init = UnixNanos::default();
957
958 let bid_updates = vec![("98450.00".to_string(), "1.5".to_string())];
959 let ask_updates = vec![("98451.00".to_string(), "2.0".to_string())];
960 let bid_removals = vec!["98449.00".to_string()];
961 let ask_removals = vec!["98452.00".to_string()];
962
963 let deltas = converter
964 .convert_delta_update(
965 instrument_id,
966 123,
967 ts_event,
968 ts_init,
969 &bid_updates,
970 &ask_updates,
971 &bid_removals,
972 &ask_removals,
973 )
974 .unwrap();
975
976 assert_eq!(deltas.instrument_id, instrument_id);
977 assert_eq!(deltas.deltas.len(), 4); assert_eq!(deltas.sequence, 123);
979
980 let first_delta = &deltas.deltas[0];
982 assert_eq!(first_delta.instrument_id, instrument_id);
983 assert_eq!(first_delta.action, BookAction::Delete);
984 assert_eq!(first_delta.order.side, OrderSide::Buy);
985 assert_eq!(first_delta.order.price, Price::from("98449.00"));
986 assert_eq!(first_delta.order.size, Quantity::from("0"));
987 assert_eq!(first_delta.order.order_id, 123000);
988 assert_eq!(first_delta.flags, 0);
989 assert_eq!(first_delta.sequence, 123);
990 assert_eq!(first_delta.ts_event, ts_event);
991 assert_eq!(first_delta.ts_init, ts_init);
992 }
993
994 #[rstest]
995 fn test_price_size_parsing() {
996 let instrument_id = test_instrument_id();
997 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
998
999 let price = parse_price("98450.50", &config).unwrap();
1000 assert_eq!(price.to_string(), "98450.50");
1001
1002 let size = parse_size("2.5", &config).unwrap();
1003 assert_eq!(size.to_string(), "2.5");
1004 }
1005
1006 #[rstest]
1007 fn test_hyperliquid_instrument_mini_info() {
1008 let instrument_id = test_instrument_id();
1009
1010 let config = HyperliquidInstrumentInfo::new(instrument_id, 4, 6);
1012 assert_eq!(config.instrument_id, instrument_id);
1013 assert_eq!(config.price_decimals, 4);
1014 assert_eq!(config.size_decimals, 6);
1015
1016 let default_config = HyperliquidInstrumentInfo::default_crypto(instrument_id);
1018 assert_eq!(default_config.instrument_id, instrument_id);
1019 assert_eq!(default_config.price_decimals, 2);
1020 assert_eq!(default_config.size_decimals, 5);
1021 }
1022
1023 #[rstest]
1024 fn test_invalid_price_parsing() {
1025 let instrument_id = test_instrument_id();
1026 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1027
1028 let result = parse_price("invalid", &config);
1030 assert!(result.is_err());
1031
1032 match result.unwrap_err() {
1033 ConversionError::InvalidPrice { value } => {
1034 assert_eq!(value, "invalid");
1035 assert!(value.contains("invalid"));
1037 }
1038 _ => panic!("Expected InvalidPrice error"),
1039 }
1040
1041 let size_result = parse_size("not_a_number", &config);
1043 assert!(size_result.is_err());
1044
1045 match size_result.unwrap_err() {
1046 ConversionError::InvalidSize { value } => {
1047 assert_eq!(value, "not_a_number");
1048 assert!(value.contains("not_a_number"));
1050 }
1051 _ => panic!("Expected InvalidSize error"),
1052 }
1053 }
1054
1055 #[rstest]
1056 fn test_configuration() {
1057 let mut converter = HyperliquidDataConverter::new();
1058 let eth_id = InstrumentId::from("ETH.HYPER");
1059 let config = HyperliquidInstrumentInfo::new(eth_id, 4, 8);
1060
1061 let asset = Ustr::from("ETH");
1062
1063 converter.configure_instrument(asset.as_str(), config.clone());
1064
1065 let retrieved_config = converter.get_config(&asset);
1067 assert_eq!(retrieved_config.instrument_id, eth_id);
1068 assert_eq!(retrieved_config.price_decimals, 4);
1069 assert_eq!(retrieved_config.size_decimals, 8);
1070
1071 let default_config = converter.get_config(&Ustr::from("UNKNOWN"));
1073 assert_eq!(
1074 default_config.instrument_id,
1075 InstrumentId::from("UNKNOWN.HYPER")
1076 );
1077 assert_eq!(default_config.price_decimals, 2);
1078 assert_eq!(default_config.size_decimals, 5);
1079
1080 assert_eq!(config.instrument_id, eth_id);
1082 assert_eq!(config.price_decimals, 4);
1083 assert_eq!(config.size_decimals, 8);
1084 }
1085
1086 #[rstest]
1087 fn test_instrument_info_creation() {
1088 let instrument_id = InstrumentId::from("BTC.HYPER");
1089 let info = HyperliquidInstrumentInfo::with_metadata(
1090 instrument_id,
1091 2,
1092 5,
1093 dec!(0.01),
1094 dec!(0.00001),
1095 dec!(10),
1096 );
1097
1098 assert_eq!(info.instrument_id, instrument_id);
1099 assert_eq!(info.price_decimals, 2);
1100 assert_eq!(info.size_decimals, 5);
1101 assert_eq!(info.tick_size, Some(dec!(0.01)));
1102 assert_eq!(info.step_size, Some(dec!(0.00001)));
1103 assert_eq!(info.min_notional, Some(dec!(10)));
1104 }
1105
1106 #[rstest]
1107 fn test_instrument_info_with_precision() {
1108 let instrument_id = test_instrument_id();
1109 let info = HyperliquidInstrumentInfo::with_precision(instrument_id, 3, 4);
1110 assert_eq!(info.instrument_id, instrument_id);
1111 assert_eq!(info.price_decimals, 3);
1112 assert_eq!(info.size_decimals, 4);
1113 assert_eq!(info.tick_size, Some(dec!(0.001))); assert_eq!(info.step_size, Some(dec!(0.0001))); }
1116
1117 #[tokio::test]
1118 async fn test_instrument_cache_basic_operations() {
1119 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1120 InstrumentId::from("BTC.HYPER"),
1121 2,
1122 5,
1123 dec!(0.01),
1124 dec!(0.00001),
1125 dec!(10),
1126 );
1127
1128 let eth_info = HyperliquidInstrumentInfo::with_metadata(
1129 InstrumentId::from("ETH.HYPER"),
1130 2,
1131 4,
1132 dec!(0.01),
1133 dec!(0.0001),
1134 dec!(10),
1135 );
1136
1137 let mut cache = HyperliquidInstrumentCache::new();
1138
1139 cache.insert("BTC", btc_info.clone());
1141 cache.insert("ETH", eth_info.clone());
1142
1143 let retrieved_btc = cache.get("BTC").unwrap();
1145 assert_eq!(retrieved_btc.instrument_id, btc_info.instrument_id);
1146 assert_eq!(retrieved_btc.size_decimals, 5);
1147
1148 let retrieved_eth = cache.get("ETH").unwrap();
1150 assert_eq!(retrieved_eth.instrument_id, eth_info.instrument_id);
1151 assert_eq!(retrieved_eth.size_decimals, 4);
1152
1153 assert_eq!(cache.len(), 2);
1155 assert!(!cache.is_empty());
1156
1157 assert!(cache.contains("BTC"));
1159 assert!(cache.contains("ETH"));
1160 assert!(!cache.contains("UNKNOWN"));
1161
1162 let all_instruments = cache.get_all();
1164 assert_eq!(all_instruments.len(), 2);
1165 }
1166
1167 #[rstest]
1168 fn test_instrument_cache_empty() {
1169 let cache = HyperliquidInstrumentCache::new();
1170 let result = cache.get("UNKNOWN");
1171 assert!(result.is_none());
1172 assert!(cache.is_empty());
1173 assert_eq!(cache.len(), 0);
1174 }
1175
1176 #[rstest]
1177 fn test_normalize_order_for_symbol() {
1178 use rust_decimal_macros::dec;
1179
1180 let mut converter = HyperliquidDataConverter::new();
1181
1182 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1184 InstrumentId::from("BTC.HYPER"),
1185 2,
1186 5,
1187 dec!(0.01), dec!(0.00001), dec!(10.0), );
1191 converter.configure_instrument("BTC", btc_info);
1192
1193 let result = converter.normalize_order_for_symbol(
1195 "BTC",
1196 dec!(50123.456789), dec!(0.123456789), );
1199
1200 assert!(result.is_ok());
1201 let (price, qty) = result.unwrap();
1202 assert_eq!(price, dec!(50123.00));
1204 assert_eq!(qty, dec!(0.12345)); let result_eth = converter.normalize_order_for_symbol("ETH", dec!(3000.123), dec!(1.23456));
1208 assert!(result_eth.is_ok());
1209
1210 let result_fail = converter.normalize_order_for_symbol(
1212 "BTC",
1213 dec!(1.0), dec!(0.001), );
1216 assert!(result_fail.is_err());
1217 assert!(result_fail.unwrap_err().contains("Notional value"));
1218 }
1219
1220 #[rstest]
1221 fn test_hyperliquid_balance_creation_and_properties() {
1222 use rust_decimal_macros::dec;
1223
1224 let asset = "USD".to_string();
1225 let total = dec!(1000.0);
1226 let available = dec!(750.0);
1227 let sequence = 42;
1228 let ts_event = UnixNanos::default();
1229
1230 let balance = HyperliquidBalance::new(asset.clone(), total, available, sequence, ts_event);
1231
1232 assert_eq!(balance.asset, asset);
1233 assert_eq!(balance.total, total);
1234 assert_eq!(balance.available, available);
1235 assert_eq!(balance.sequence, sequence);
1236 assert_eq!(balance.ts_event, ts_event);
1237 assert_eq!(balance.locked(), dec!(250.0)); let full_balance = HyperliquidBalance::new(
1241 "ETH".to_string(),
1242 dec!(100.0),
1243 dec!(100.0),
1244 1,
1245 UnixNanos::default(),
1246 );
1247 assert_eq!(full_balance.locked(), dec!(0.0));
1248
1249 let weird_balance = HyperliquidBalance::new(
1251 "WEIRD".to_string(),
1252 dec!(50.0),
1253 dec!(60.0),
1254 1,
1255 UnixNanos::default(),
1256 );
1257 assert_eq!(weird_balance.locked(), dec!(0.0));
1258 }
1259
1260 #[rstest]
1261 fn test_hyperliquid_account_state_creation() {
1262 let state = HyperliquidAccountState::new();
1263 assert!(state.balances.is_empty());
1264 assert_eq!(state.last_sequence, 0);
1265
1266 let default_state = HyperliquidAccountState::default();
1267 assert!(default_state.balances.is_empty());
1268 assert_eq!(default_state.last_sequence, 0);
1269 }
1270
1271 #[rstest]
1272 fn test_hyperliquid_account_state_getters() {
1273 use rust_decimal_macros::dec;
1274
1275 let mut state = HyperliquidAccountState::new();
1276
1277 let balance = state.get_balance("USD");
1279 assert_eq!(balance.asset, "USD");
1280 assert_eq!(balance.total, dec!(0.0));
1281 assert_eq!(balance.available, dec!(0.0));
1282
1283 let real_balance = HyperliquidBalance::new(
1285 "USD".to_string(),
1286 dec!(1000.0),
1287 dec!(750.0),
1288 1,
1289 UnixNanos::default(),
1290 );
1291 state.balances.insert("USD".to_string(), real_balance);
1292
1293 let retrieved_balance = state.get_balance("USD");
1295 assert_eq!(retrieved_balance.total, dec!(1000.0));
1296 }
1297
1298 #[rstest]
1299 fn test_hyperliquid_account_state_account_value() {
1300 use rust_decimal_macros::dec;
1301
1302 let mut state = HyperliquidAccountState::new();
1303
1304 state.balances.insert(
1306 "USD".to_string(),
1307 HyperliquidBalance::new(
1308 "USD".to_string(),
1309 dec!(10000.0),
1310 dec!(5000.0),
1311 1,
1312 UnixNanos::default(),
1313 ),
1314 );
1315
1316 let total_value = state.account_value();
1317 assert_eq!(total_value, dec!(10000.0));
1318
1319 state.balances.clear();
1321 let no_balance_value = state.account_value();
1322 assert_eq!(no_balance_value, dec!(0.0));
1323 }
1324
1325 #[rstest]
1326 fn test_hyperliquid_account_event_balance_snapshot() {
1327 use rust_decimal_macros::dec;
1328
1329 let mut state = HyperliquidAccountState::new();
1330
1331 let balance = HyperliquidBalance::new(
1332 "USD".to_string(),
1333 dec!(1000.0),
1334 dec!(750.0),
1335 10,
1336 UnixNanos::default(),
1337 );
1338
1339 let snapshot_event = HyperliquidAccountEvent::BalanceSnapshot {
1340 balances: vec![balance],
1341 sequence: 10,
1342 };
1343
1344 state.apply(snapshot_event);
1345
1346 assert_eq!(state.balances.len(), 1);
1347 assert_eq!(state.last_sequence, 10);
1348 assert_eq!(state.get_balance("USD").total, dec!(1000.0));
1349 }
1350
1351 #[rstest]
1352 fn test_hyperliquid_account_event_balance_delta() {
1353 use rust_decimal_macros::dec;
1354
1355 let mut state = HyperliquidAccountState::new();
1356
1357 let initial_balance = HyperliquidBalance::new(
1359 "USD".to_string(),
1360 dec!(1000.0),
1361 dec!(750.0),
1362 5,
1363 UnixNanos::default(),
1364 );
1365 state.balances.insert("USD".to_string(), initial_balance);
1366 state.last_sequence = 5;
1367
1368 let updated_balance = HyperliquidBalance::new(
1370 "USD".to_string(),
1371 dec!(1200.0),
1372 dec!(900.0),
1373 10,
1374 UnixNanos::default(),
1375 );
1376
1377 let delta_event = HyperliquidAccountEvent::BalanceDelta {
1378 balance: updated_balance,
1379 };
1380
1381 state.apply(delta_event);
1382
1383 let balance = state.get_balance("USD");
1384 assert_eq!(balance.total, dec!(1200.0));
1385 assert_eq!(balance.available, dec!(900.0));
1386 assert_eq!(balance.sequence, 10);
1387 assert_eq!(state.last_sequence, 10);
1388
1389 let old_balance = HyperliquidBalance::new(
1391 "USD".to_string(),
1392 dec!(800.0),
1393 dec!(600.0),
1394 8,
1395 UnixNanos::default(),
1396 );
1397
1398 let old_delta_event = HyperliquidAccountEvent::BalanceDelta {
1399 balance: old_balance,
1400 };
1401
1402 state.apply(old_delta_event);
1403
1404 let balance = state.get_balance("USD");
1406 assert_eq!(balance.total, dec!(1200.0)); assert_eq!(balance.sequence, 10); assert_eq!(state.last_sequence, 10); }
1410
1411 #[rstest]
1412 fn test_hyperliquid_account_state_to_account_state_uses_from_total_and_free() {
1413 use nautilus_model::identifiers::AccountId;
1414
1415 let mut state = HyperliquidAccountState::new();
1416 state.balances.insert(
1417 "USDC".to_string(),
1418 HyperliquidBalance::new(
1419 "USDC".to_string(),
1420 dec!(10_000),
1421 dec!(7_500),
1422 1,
1423 UnixNanos::default(),
1424 ),
1425 );
1426 state.balances.insert(
1427 "BTC".to_string(),
1428 HyperliquidBalance::new(
1429 "BTC".to_string(),
1430 dec!(1.25),
1431 dec!(1.0),
1432 2,
1433 UnixNanos::default(),
1434 ),
1435 );
1436
1437 let account_id = AccountId::new("HYPERLIQUID-001");
1438 let ts = UnixNanos::default();
1439 let account_state = state.to_account_state(account_id, ts, ts).unwrap();
1440
1441 assert_eq!(account_state.account_id, account_id);
1442 assert_eq!(account_state.balances.len(), 2);
1443
1444 let usdc = account_state
1445 .balances
1446 .iter()
1447 .find(|b| b.currency.code.as_str() == "USDC")
1448 .expect("USDC balance emitted");
1449 assert_eq!(usdc.total.as_decimal(), dec!(10_000));
1450 assert_eq!(usdc.free.as_decimal(), dec!(7_500));
1451 assert_eq!(usdc.locked.as_decimal(), dec!(2_500));
1452
1453 let btc = account_state
1454 .balances
1455 .iter()
1456 .find(|b| b.currency.code.as_str() == "BTC")
1457 .expect("BTC balance emitted");
1458 assert_eq!(btc.total.as_decimal(), dec!(1.25));
1459 assert_eq!(btc.free.as_decimal(), dec!(1.0));
1460 assert_eq!(btc.locked.as_decimal(), dec!(0.25));
1461 }
1462}