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, Money, Price, Quantity},
27};
28use rust_decimal::Decimal;
29use ustr::Ustr;
30
31use crate::{
32 http::{
33 models::{HyperliquidL2Book, HyperliquidLevel},
34 parse::get_currency,
35 },
36 websocket::messages::{WsBookData, WsLevelData},
37};
38
39#[derive(Debug, Clone)]
41pub struct HyperliquidInstrumentInfo {
42 pub instrument_id: InstrumentId,
43 pub price_decimals: u8,
44 pub size_decimals: u8,
45 pub tick_size: Option<Decimal>,
47 pub step_size: Option<Decimal>,
49 pub min_notional: Option<Decimal>,
51}
52
53impl HyperliquidInstrumentInfo {
54 pub fn new(instrument_id: InstrumentId, price_decimals: u8, size_decimals: u8) -> Self {
56 Self {
57 instrument_id,
58 price_decimals,
59 size_decimals,
60 tick_size: None,
61 step_size: None,
62 min_notional: None,
63 }
64 }
65
66 pub fn with_metadata(
68 instrument_id: InstrumentId,
69 price_decimals: u8,
70 size_decimals: u8,
71 tick_size: Decimal,
72 step_size: Decimal,
73 min_notional: Decimal,
74 ) -> Self {
75 Self {
76 instrument_id,
77 price_decimals,
78 size_decimals,
79 tick_size: Some(tick_size),
80 step_size: Some(step_size),
81 min_notional: Some(min_notional),
82 }
83 }
84
85 pub fn with_precision(
87 instrument_id: InstrumentId,
88 price_decimals: u8,
89 size_decimals: u8,
90 ) -> Self {
91 let tick_size = Decimal::new(1, price_decimals as u32);
92 let step_size = Decimal::new(1, size_decimals as u32);
93 Self {
94 instrument_id,
95 price_decimals,
96 size_decimals,
97 tick_size: Some(tick_size),
98 step_size: Some(step_size),
99 min_notional: None,
100 }
101 }
102
103 pub fn default_crypto(instrument_id: InstrumentId) -> Self {
105 Self::with_precision(instrument_id, 2, 5) }
107}
108
109#[derive(Debug, Default)]
111pub struct HyperliquidInstrumentCache {
112 instruments_by_symbol: AHashMap<Ustr, HyperliquidInstrumentInfo>,
113}
114
115impl HyperliquidInstrumentCache {
116 pub fn new() -> Self {
118 Self {
119 instruments_by_symbol: AHashMap::new(),
120 }
121 }
122
123 pub fn insert(&mut self, symbol: &str, info: HyperliquidInstrumentInfo) {
125 self.instruments_by_symbol.insert(Ustr::from(symbol), info);
126 }
127
128 pub fn get(&self, symbol: &str) -> Option<&HyperliquidInstrumentInfo> {
130 self.instruments_by_symbol.get(&Ustr::from(symbol))
131 }
132
133 pub fn get_all(&self) -> Vec<&HyperliquidInstrumentInfo> {
135 self.instruments_by_symbol.values().collect()
136 }
137
138 pub fn contains(&self, symbol: &str) -> bool {
140 self.instruments_by_symbol.contains_key(&Ustr::from(symbol))
141 }
142
143 pub fn len(&self) -> usize {
145 self.instruments_by_symbol.len()
146 }
147
148 pub fn is_empty(&self) -> bool {
150 self.instruments_by_symbol.is_empty()
151 }
152
153 pub fn clear(&mut self) {
155 self.instruments_by_symbol.clear();
156 }
157}
158
159#[derive(Clone, Debug, PartialEq, Eq, Hash)]
161pub enum HyperliquidTradeKey {
162 Id(String),
164 Seq(u64),
166}
167
168#[derive(Debug)]
170pub struct HyperliquidDataConverter {
171 configs: AHashMap<Ustr, HyperliquidInstrumentInfo>,
173}
174
175impl Default for HyperliquidDataConverter {
176 fn default() -> Self {
177 Self::new()
178 }
179}
180
181impl HyperliquidDataConverter {
182 pub fn new() -> Self {
184 Self {
185 configs: AHashMap::new(),
186 }
187 }
188
189 pub fn normalize_order_for_symbol(
194 &mut self,
195 symbol: &str,
196 price: Decimal,
197 qty: Decimal,
198 ) -> Result<(Decimal, Decimal), String> {
199 let config = self.get_config(&Ustr::from(symbol));
200
201 let tick_size = config.tick_size.unwrap_or_else(|| Decimal::new(1, 2)); let step_size = config.step_size.unwrap_or_else(|| {
204 match config.size_decimals {
206 0 => Decimal::ONE,
207 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), }
214 });
215 let min_notional = config.min_notional.unwrap_or_else(|| Decimal::from(10)); crate::common::parse::normalize_order(
218 price,
219 qty,
220 tick_size,
221 step_size,
222 min_notional,
223 config.price_decimals,
224 config.size_decimals,
225 )
226 }
227
228 pub fn configure_instrument(&mut self, symbol: &str, config: HyperliquidInstrumentInfo) {
230 self.configs.insert(Ustr::from(symbol), config);
231 }
232
233 fn get_config(&self, symbol: &Ustr) -> HyperliquidInstrumentInfo {
235 self.configs.get(symbol).cloned().unwrap_or_else(|| {
236 let instrument_id = InstrumentId::from(format!("{symbol}.HYPER"));
238 HyperliquidInstrumentInfo::default_crypto(instrument_id)
239 })
240 }
241
242 pub fn convert_http_snapshot(
244 &self,
245 data: &HyperliquidL2Book,
246 instrument_id: InstrumentId,
247 ts_init: UnixNanos,
248 ) -> Result<OrderBookDeltas, ConversionError> {
249 let config = self.get_config(&data.coin);
250 let mut deltas = Vec::new();
251
252 deltas.push(OrderBookDelta::clear(
254 instrument_id,
255 0, UnixNanos::from(data.time * 1_000_000), ts_init,
258 ));
259
260 let mut order_id = 1u64; for level in &data.levels[0] {
264 let (price, size) = parse_level(level, &config)?;
265 if size.is_positive() {
266 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
267 deltas.push(OrderBookDelta::new(
268 instrument_id,
269 BookAction::Add,
270 order,
271 RecordFlag::F_LAST as u8, order_id,
273 UnixNanos::from(data.time * 1_000_000),
274 ts_init,
275 ));
276 order_id += 1;
277 }
278 }
279
280 for level in &data.levels[1] {
282 let (price, size) = parse_level(level, &config)?;
283 if size.is_positive() {
284 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
285 deltas.push(OrderBookDelta::new(
286 instrument_id,
287 BookAction::Add,
288 order,
289 RecordFlag::F_LAST as u8, order_id,
291 UnixNanos::from(data.time * 1_000_000),
292 ts_init,
293 ));
294 order_id += 1;
295 }
296 }
297
298 Ok(OrderBookDeltas::new(instrument_id, deltas))
299 }
300
301 pub fn convert_ws_snapshot(
303 &self,
304 data: &WsBookData,
305 instrument_id: InstrumentId,
306 ts_init: UnixNanos,
307 ) -> Result<OrderBookDeltas, ConversionError> {
308 let config = self.get_config(&data.coin);
309 let mut deltas = Vec::new();
310
311 deltas.push(OrderBookDelta::clear(
313 instrument_id,
314 0, UnixNanos::from(data.time * 1_000_000), ts_init,
317 ));
318
319 let mut order_id = 1u64; for level in &data.levels[0] {
323 let (price, size) = parse_ws_level(level, &config)?;
324 if size.is_positive() {
325 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
326 deltas.push(OrderBookDelta::new(
327 instrument_id,
328 BookAction::Add,
329 order,
330 RecordFlag::F_LAST as u8,
331 order_id,
332 UnixNanos::from(data.time * 1_000_000),
333 ts_init,
334 ));
335 order_id += 1;
336 }
337 }
338
339 for level in &data.levels[1] {
341 let (price, size) = parse_ws_level(level, &config)?;
342 if size.is_positive() {
343 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
344 deltas.push(OrderBookDelta::new(
345 instrument_id,
346 BookAction::Add,
347 order,
348 RecordFlag::F_LAST as u8,
349 order_id,
350 UnixNanos::from(data.time * 1_000_000),
351 ts_init,
352 ));
353 order_id += 1;
354 }
355 }
356
357 Ok(OrderBookDeltas::new(instrument_id, deltas))
358 }
359
360 #[allow(clippy::too_many_arguments)]
363 pub fn convert_delta_update(
364 &self,
365 instrument_id: InstrumentId,
366 sequence: u64,
367 ts_event: UnixNanos,
368 ts_init: UnixNanos,
369 bid_updates: &[(String, String)], ask_updates: &[(String, String)], bid_removals: &[String], ask_removals: &[String], ) -> Result<OrderBookDeltas, ConversionError> {
374 let config = self.get_config(&instrument_id.symbol.inner());
375 let mut deltas = Vec::new();
376 let mut order_id = sequence * 1000; for price_str in bid_removals {
380 let price = parse_price(price_str, &config)?;
381 let order = BookOrder::new(OrderSide::Buy, price, Quantity::from("0"), order_id);
382 deltas.push(OrderBookDelta::new(
383 instrument_id,
384 BookAction::Delete,
385 order,
386 0, sequence,
388 ts_event,
389 ts_init,
390 ));
391 order_id += 1;
392 }
393
394 for price_str in ask_removals {
396 let price = parse_price(price_str, &config)?;
397 let order = BookOrder::new(OrderSide::Sell, price, Quantity::from("0"), order_id);
398 deltas.push(OrderBookDelta::new(
399 instrument_id,
400 BookAction::Delete,
401 order,
402 0, sequence,
404 ts_event,
405 ts_init,
406 ));
407 order_id += 1;
408 }
409
410 for (price_str, size_str) in bid_updates {
412 let price = parse_price(price_str, &config)?;
413 let size = parse_size(size_str, &config)?;
414
415 if size.is_positive() {
416 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
417 deltas.push(OrderBookDelta::new(
418 instrument_id,
419 BookAction::Update, order,
421 0, sequence,
423 ts_event,
424 ts_init,
425 ));
426 } else {
427 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
429 deltas.push(OrderBookDelta::new(
430 instrument_id,
431 BookAction::Delete,
432 order,
433 0, sequence,
435 ts_event,
436 ts_init,
437 ));
438 }
439 order_id += 1;
440 }
441
442 for (price_str, size_str) in ask_updates {
444 let price = parse_price(price_str, &config)?;
445 let size = parse_size(size_str, &config)?;
446
447 if size.is_positive() {
448 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
449 deltas.push(OrderBookDelta::new(
450 instrument_id,
451 BookAction::Update, order,
453 0, sequence,
455 ts_event,
456 ts_init,
457 ));
458 } else {
459 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
461 deltas.push(OrderBookDelta::new(
462 instrument_id,
463 BookAction::Delete,
464 order,
465 0, sequence,
467 ts_event,
468 ts_init,
469 ));
470 }
471 order_id += 1;
472 }
473
474 Ok(OrderBookDeltas::new(instrument_id, deltas))
475 }
476}
477
478fn parse_level(
480 level: &HyperliquidLevel,
481 inst_info: &HyperliquidInstrumentInfo,
482) -> Result<(Price, Quantity), ConversionError> {
483 let price = parse_price(&level.px, inst_info)?;
484 let size = parse_size(&level.sz, inst_info)?;
485 Ok((price, size))
486}
487
488fn parse_ws_level(
490 level: &WsLevelData,
491 config: &HyperliquidInstrumentInfo,
492) -> Result<(Price, Quantity), ConversionError> {
493 let price = parse_price(&level.px, config)?;
494 let size = parse_size(&level.sz, config)?;
495 Ok((price, size))
496}
497
498fn parse_price(
500 price_str: &str,
501 _config: &HyperliquidInstrumentInfo,
502) -> Result<Price, ConversionError> {
503 let _decimal = Decimal::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
504 value: price_str.to_string(),
505 })?;
506
507 Price::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
508 value: price_str.to_string(),
509 })
510}
511
512fn parse_size(
514 size_str: &str,
515 _config: &HyperliquidInstrumentInfo,
516) -> Result<Quantity, ConversionError> {
517 let _decimal = Decimal::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
518 value: size_str.to_string(),
519 })?;
520
521 Quantity::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
522 value: size_str.to_string(),
523 })
524}
525
526#[derive(Debug, Clone, PartialEq, Eq)]
528pub enum ConversionError {
529 InvalidPrice { value: String },
531 InvalidSize { value: String },
533 OrderBookDeltasError(String),
535}
536
537impl From<anyhow::Error> for ConversionError {
538 fn from(err: anyhow::Error) -> Self {
539 Self::OrderBookDeltasError(err.to_string())
540 }
541}
542
543impl Display for ConversionError {
544 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545 match self {
546 Self::InvalidPrice { value } => write!(f, "Invalid price: {value}"),
547 Self::InvalidSize { value } => write!(f, "Invalid size: {value}"),
548 Self::OrderBookDeltasError(msg) => {
549 write!(f, "OrderBookDeltas error: {msg}")
550 }
551 }
552 }
553}
554
555impl std::error::Error for ConversionError {}
556
557#[derive(Clone, Debug)]
566pub struct HyperliquidPositionData {
567 pub asset: String,
568 pub position: Decimal, pub entry_px: Option<Decimal>,
570 pub unrealized_pnl: Decimal,
571 pub cumulative_funding: Option<Decimal>,
572 pub position_value: Decimal,
573}
574
575impl HyperliquidPositionData {
576 pub fn is_flat(&self) -> bool {
578 self.position.is_zero()
579 }
580
581 pub fn is_long(&self) -> bool {
583 self.position > Decimal::ZERO
584 }
585
586 pub fn is_short(&self) -> bool {
588 self.position < Decimal::ZERO
589 }
590}
591
592#[derive(Clone, Debug)]
600pub struct HyperliquidBalance {
601 pub asset: String,
602 pub total: Decimal,
603 pub available: Decimal,
604 pub sequence: u64,
605 pub ts_event: UnixNanos,
606}
607
608impl HyperliquidBalance {
609 pub fn new(
610 asset: String,
611 total: Decimal,
612 available: Decimal,
613 sequence: u64,
614 ts_event: UnixNanos,
615 ) -> Self {
616 Self {
617 asset,
618 total,
619 available,
620 sequence,
621 ts_event,
622 }
623 }
624
625 pub fn locked(&self) -> Decimal {
627 (self.total - self.available).max(Decimal::ZERO)
628 }
629}
630
631#[derive(Default, Debug)]
639pub struct HyperliquidAccountState {
640 pub balances: AHashMap<String, HyperliquidBalance>,
641 pub last_sequence: u64,
642}
643
644impl HyperliquidAccountState {
645 pub fn new() -> Self {
646 Default::default()
647 }
648
649 pub fn get_balance(&self, asset: &str) -> HyperliquidBalance {
651 self.balances.get(asset).cloned().unwrap_or_else(|| {
652 HyperliquidBalance::new(
653 asset.to_string(),
654 Decimal::ZERO,
655 Decimal::ZERO,
656 0,
657 UnixNanos::default(),
658 )
659 })
660 }
661
662 pub fn account_value(&self) -> Decimal {
666 self.balances.values().map(|balance| balance.total).sum()
667 }
668
669 pub fn to_account_state(
678 &self,
679 account_id: AccountId,
680 ts_event: UnixNanos,
681 ts_init: UnixNanos,
682 ) -> anyhow::Result<AccountState> {
683 let balances: Vec<AccountBalance> = self
685 .balances
686 .values()
687 .map(|balance| {
688 let currency = get_currency(&balance.asset);
690
691 let total = Money::from_decimal(balance.total, currency)?;
692 let free = Money::from_decimal(balance.available, currency)?;
693 let locked = total - free;
694
695 Ok(AccountBalance::new(total, locked, free))
696 })
697 .collect::<anyhow::Result<Vec<_>>>()?;
698
699 let margins = Vec::new();
701
702 let account_type = AccountType::Margin;
703 let is_reported = true;
704 let event_id = UUID4::new();
705
706 Ok(AccountState::new(
707 account_id,
708 account_type,
709 balances,
710 margins,
711 is_reported,
712 event_id,
713 ts_event,
714 ts_init,
715 None, ))
717 }
718}
719
720#[derive(Debug, Clone)]
730pub enum HyperliquidAccountEvent {
731 BalanceSnapshot {
733 balances: Vec<HyperliquidBalance>,
734 sequence: u64,
735 },
736 BalanceDelta { balance: HyperliquidBalance },
738}
739
740impl HyperliquidAccountState {
741 pub fn apply(&mut self, event: HyperliquidAccountEvent) {
743 match event {
744 HyperliquidAccountEvent::BalanceSnapshot { balances, sequence } => {
745 self.balances.clear();
746
747 for balance in balances {
748 self.balances.insert(balance.asset.clone(), balance);
749 }
750
751 self.last_sequence = sequence;
752 }
753 HyperliquidAccountEvent::BalanceDelta { balance } => {
754 let sequence = balance.sequence;
755 let entry = self
756 .balances
757 .entry(balance.asset.clone())
758 .or_insert_with(|| balance.clone());
759
760 if sequence > entry.sequence {
762 *entry = balance;
763 self.last_sequence = self.last_sequence.max(sequence);
764 }
765 }
766 }
767 }
768}
769
770pub fn parse_position_status_report(
780 position_data: &HyperliquidPositionData,
781 account_id: AccountId,
782 instrument_id: InstrumentId,
783 ts_init: UnixNanos,
784) -> anyhow::Result<PositionStatusReport> {
785 let position_side = if position_data.is_flat() {
787 PositionSide::Flat
788 } else if position_data.is_long() {
789 PositionSide::Long
790 } else {
791 PositionSide::Short
792 };
793
794 let quantity = Quantity::from_decimal(position_data.position.abs())?;
796
797 let ts_last = ts_init;
798 let avg_px_open = position_data.entry_px;
799
800 Ok(PositionStatusReport::new(
801 account_id,
802 instrument_id,
803 position_side.as_specified(),
804 quantity,
805 ts_last,
806 ts_init,
807 None, None, avg_px_open,
810 ))
811}
812
813#[cfg(test)]
814#[allow(dead_code)]
815mod tests {
816 use rstest::rstest;
817 use rust_decimal_macros::dec;
818
819 use super::*;
820
821 fn load_test_data<T>(filename: &str) -> T
822 where
823 T: serde::de::DeserializeOwned,
824 {
825 let path = format!("test_data/{filename}");
826 let content = std::fs::read_to_string(path).expect("Failed to read test data");
827 serde_json::from_str(&content).expect("Failed to parse test data")
828 }
829
830 fn test_instrument_id() -> InstrumentId {
831 InstrumentId::from("BTC.HYPER")
832 }
833
834 fn sample_http_book() -> HyperliquidL2Book {
835 load_test_data("http_l2_book_snapshot.json")
836 }
837
838 fn sample_ws_book() -> WsBookData {
839 load_test_data("ws_book_data.json")
840 }
841
842 #[rstest]
843 fn test_http_snapshot_conversion() {
844 let converter = HyperliquidDataConverter::new();
845 let book_data = sample_http_book();
846 let instrument_id = test_instrument_id();
847 let ts_init = UnixNanos::default();
848
849 let deltas = converter
850 .convert_http_snapshot(&book_data, instrument_id, ts_init)
851 .unwrap();
852
853 assert_eq!(deltas.instrument_id, instrument_id);
854 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
858 assert_eq!(clear_delta.instrument_id, instrument_id);
859 assert_eq!(clear_delta.action, BookAction::Clear);
860 assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
861 assert_eq!(clear_delta.order.price.raw, 0);
862 assert_eq!(clear_delta.order.price.precision, 0);
863 assert_eq!(clear_delta.order.size.raw, 0);
864 assert_eq!(clear_delta.order.size.precision, 0);
865 assert_eq!(clear_delta.order.order_id, 0);
866 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
867 assert_eq!(clear_delta.sequence, 0);
868 assert_eq!(
869 clear_delta.ts_event,
870 UnixNanos::from(book_data.time * 1_000_000)
871 );
872 assert_eq!(clear_delta.ts_init, ts_init);
873
874 let first_bid_delta = &deltas.deltas[1];
876 assert_eq!(first_bid_delta.instrument_id, instrument_id);
877 assert_eq!(first_bid_delta.action, BookAction::Add);
878 assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
879 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
880 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
881 assert_eq!(first_bid_delta.order.order_id, 1);
882 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
883 assert_eq!(first_bid_delta.sequence, 1);
884 assert_eq!(
885 first_bid_delta.ts_event,
886 UnixNanos::from(book_data.time * 1_000_000)
887 );
888 assert_eq!(first_bid_delta.ts_init, ts_init);
889
890 for delta in &deltas.deltas[1..] {
892 assert_eq!(delta.action, BookAction::Add);
893 assert!(delta.order.size.is_positive());
894 }
895 }
896
897 #[rstest]
898 fn test_ws_snapshot_conversion() {
899 let converter = HyperliquidDataConverter::new();
900 let book_data = sample_ws_book();
901 let instrument_id = test_instrument_id();
902 let ts_init = UnixNanos::default();
903
904 let deltas = converter
905 .convert_ws_snapshot(&book_data, instrument_id, ts_init)
906 .unwrap();
907
908 assert_eq!(deltas.instrument_id, instrument_id);
909 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
913 assert_eq!(clear_delta.instrument_id, instrument_id);
914 assert_eq!(clear_delta.action, BookAction::Clear);
915 assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
916 assert_eq!(clear_delta.order.price.raw, 0);
917 assert_eq!(clear_delta.order.price.precision, 0);
918 assert_eq!(clear_delta.order.size.raw, 0);
919 assert_eq!(clear_delta.order.size.precision, 0);
920 assert_eq!(clear_delta.order.order_id, 0);
921 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
922 assert_eq!(clear_delta.sequence, 0);
923 assert_eq!(
924 clear_delta.ts_event,
925 UnixNanos::from(book_data.time * 1_000_000)
926 );
927 assert_eq!(clear_delta.ts_init, ts_init);
928
929 let first_bid_delta = &deltas.deltas[1];
931 assert_eq!(first_bid_delta.instrument_id, instrument_id);
932 assert_eq!(first_bid_delta.action, BookAction::Add);
933 assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
934 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
935 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
936 assert_eq!(first_bid_delta.order.order_id, 1);
937 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
938 assert_eq!(first_bid_delta.sequence, 1);
939 assert_eq!(
940 first_bid_delta.ts_event,
941 UnixNanos::from(book_data.time * 1_000_000)
942 );
943 assert_eq!(first_bid_delta.ts_init, ts_init);
944 }
945
946 #[rstest]
947 fn test_delta_update_conversion() {
948 let converter = HyperliquidDataConverter::new();
949 let instrument_id = test_instrument_id();
950 let ts_event = UnixNanos::default();
951 let ts_init = UnixNanos::default();
952
953 let bid_updates = vec![("98450.00".to_string(), "1.5".to_string())];
954 let ask_updates = vec![("98451.00".to_string(), "2.0".to_string())];
955 let bid_removals = vec!["98449.00".to_string()];
956 let ask_removals = vec!["98452.00".to_string()];
957
958 let deltas = converter
959 .convert_delta_update(
960 instrument_id,
961 123,
962 ts_event,
963 ts_init,
964 &bid_updates,
965 &ask_updates,
966 &bid_removals,
967 &ask_removals,
968 )
969 .unwrap();
970
971 assert_eq!(deltas.instrument_id, instrument_id);
972 assert_eq!(deltas.deltas.len(), 4); assert_eq!(deltas.sequence, 123);
974
975 let first_delta = &deltas.deltas[0];
977 assert_eq!(first_delta.instrument_id, instrument_id);
978 assert_eq!(first_delta.action, BookAction::Delete);
979 assert_eq!(first_delta.order.side, OrderSide::Buy);
980 assert_eq!(first_delta.order.price, Price::from("98449.00"));
981 assert_eq!(first_delta.order.size, Quantity::from("0"));
982 assert_eq!(first_delta.order.order_id, 123000);
983 assert_eq!(first_delta.flags, 0);
984 assert_eq!(first_delta.sequence, 123);
985 assert_eq!(first_delta.ts_event, ts_event);
986 assert_eq!(first_delta.ts_init, ts_init);
987 }
988
989 #[rstest]
990 fn test_price_size_parsing() {
991 let instrument_id = test_instrument_id();
992 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
993
994 let price = parse_price("98450.50", &config).unwrap();
995 assert_eq!(price.to_string(), "98450.50");
996
997 let size = parse_size("2.5", &config).unwrap();
998 assert_eq!(size.to_string(), "2.5");
999 }
1000
1001 #[rstest]
1002 fn test_hyperliquid_instrument_mini_info() {
1003 let instrument_id = test_instrument_id();
1004
1005 let config = HyperliquidInstrumentInfo::new(instrument_id, 4, 6);
1007 assert_eq!(config.instrument_id, instrument_id);
1008 assert_eq!(config.price_decimals, 4);
1009 assert_eq!(config.size_decimals, 6);
1010
1011 let default_config = HyperliquidInstrumentInfo::default_crypto(instrument_id);
1013 assert_eq!(default_config.instrument_id, instrument_id);
1014 assert_eq!(default_config.price_decimals, 2);
1015 assert_eq!(default_config.size_decimals, 5);
1016 }
1017
1018 #[rstest]
1019 fn test_invalid_price_parsing() {
1020 let instrument_id = test_instrument_id();
1021 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1022
1023 let result = parse_price("invalid", &config);
1025 assert!(result.is_err());
1026
1027 match result.unwrap_err() {
1028 ConversionError::InvalidPrice { value } => {
1029 assert_eq!(value, "invalid");
1030 assert!(value.contains("invalid"));
1032 }
1033 _ => panic!("Expected InvalidPrice error"),
1034 }
1035
1036 let size_result = parse_size("not_a_number", &config);
1038 assert!(size_result.is_err());
1039
1040 match size_result.unwrap_err() {
1041 ConversionError::InvalidSize { value } => {
1042 assert_eq!(value, "not_a_number");
1043 assert!(value.contains("not_a_number"));
1045 }
1046 _ => panic!("Expected InvalidSize error"),
1047 }
1048 }
1049
1050 #[rstest]
1051 fn test_configuration() {
1052 let mut converter = HyperliquidDataConverter::new();
1053 let eth_id = InstrumentId::from("ETH.HYPER");
1054 let config = HyperliquidInstrumentInfo::new(eth_id, 4, 8);
1055
1056 let asset = Ustr::from("ETH");
1057
1058 converter.configure_instrument(asset.as_str(), config.clone());
1059
1060 let retrieved_config = converter.get_config(&asset);
1062 assert_eq!(retrieved_config.instrument_id, eth_id);
1063 assert_eq!(retrieved_config.price_decimals, 4);
1064 assert_eq!(retrieved_config.size_decimals, 8);
1065
1066 let default_config = converter.get_config(&Ustr::from("UNKNOWN"));
1068 assert_eq!(
1069 default_config.instrument_id,
1070 InstrumentId::from("UNKNOWN.HYPER")
1071 );
1072 assert_eq!(default_config.price_decimals, 2);
1073 assert_eq!(default_config.size_decimals, 5);
1074
1075 assert_eq!(config.instrument_id, eth_id);
1077 assert_eq!(config.price_decimals, 4);
1078 assert_eq!(config.size_decimals, 8);
1079 }
1080
1081 #[rstest]
1082 fn test_instrument_info_creation() {
1083 let instrument_id = InstrumentId::from("BTC.HYPER");
1084 let info = HyperliquidInstrumentInfo::with_metadata(
1085 instrument_id,
1086 2,
1087 5,
1088 dec!(0.01),
1089 dec!(0.00001),
1090 dec!(10),
1091 );
1092
1093 assert_eq!(info.instrument_id, instrument_id);
1094 assert_eq!(info.price_decimals, 2);
1095 assert_eq!(info.size_decimals, 5);
1096 assert_eq!(info.tick_size, Some(dec!(0.01)));
1097 assert_eq!(info.step_size, Some(dec!(0.00001)));
1098 assert_eq!(info.min_notional, Some(dec!(10)));
1099 }
1100
1101 #[rstest]
1102 fn test_instrument_info_with_precision() {
1103 let instrument_id = test_instrument_id();
1104 let info = HyperliquidInstrumentInfo::with_precision(instrument_id, 3, 4);
1105 assert_eq!(info.instrument_id, instrument_id);
1106 assert_eq!(info.price_decimals, 3);
1107 assert_eq!(info.size_decimals, 4);
1108 assert_eq!(info.tick_size, Some(dec!(0.001))); assert_eq!(info.step_size, Some(dec!(0.0001))); }
1111
1112 #[tokio::test]
1113 async fn test_instrument_cache_basic_operations() {
1114 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1115 InstrumentId::from("BTC.HYPER"),
1116 2,
1117 5,
1118 dec!(0.01),
1119 dec!(0.00001),
1120 dec!(10),
1121 );
1122
1123 let eth_info = HyperliquidInstrumentInfo::with_metadata(
1124 InstrumentId::from("ETH.HYPER"),
1125 2,
1126 4,
1127 dec!(0.01),
1128 dec!(0.0001),
1129 dec!(10),
1130 );
1131
1132 let mut cache = HyperliquidInstrumentCache::new();
1133
1134 cache.insert("BTC", btc_info.clone());
1136 cache.insert("ETH", eth_info.clone());
1137
1138 let retrieved_btc = cache.get("BTC").unwrap();
1140 assert_eq!(retrieved_btc.instrument_id, btc_info.instrument_id);
1141 assert_eq!(retrieved_btc.size_decimals, 5);
1142
1143 let retrieved_eth = cache.get("ETH").unwrap();
1145 assert_eq!(retrieved_eth.instrument_id, eth_info.instrument_id);
1146 assert_eq!(retrieved_eth.size_decimals, 4);
1147
1148 assert_eq!(cache.len(), 2);
1150 assert!(!cache.is_empty());
1151
1152 assert!(cache.contains("BTC"));
1154 assert!(cache.contains("ETH"));
1155 assert!(!cache.contains("UNKNOWN"));
1156
1157 let all_instruments = cache.get_all();
1159 assert_eq!(all_instruments.len(), 2);
1160 }
1161
1162 #[rstest]
1163 fn test_instrument_cache_empty() {
1164 let cache = HyperliquidInstrumentCache::new();
1165 let result = cache.get("UNKNOWN");
1166 assert!(result.is_none());
1167 assert!(cache.is_empty());
1168 assert_eq!(cache.len(), 0);
1169 }
1170
1171 #[rstest]
1172 fn test_normalize_order_for_symbol() {
1173 use rust_decimal_macros::dec;
1174
1175 let mut converter = HyperliquidDataConverter::new();
1176
1177 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1179 InstrumentId::from("BTC.HYPER"),
1180 2,
1181 5,
1182 dec!(0.01), dec!(0.00001), dec!(10.0), );
1186 converter.configure_instrument("BTC", btc_info);
1187
1188 let result = converter.normalize_order_for_symbol(
1190 "BTC",
1191 dec!(50123.456789), dec!(0.123456789), );
1194
1195 assert!(result.is_ok());
1196 let (price, qty) = result.unwrap();
1197 assert_eq!(price, dec!(50123.00));
1199 assert_eq!(qty, dec!(0.12345)); let result_eth = converter.normalize_order_for_symbol("ETH", dec!(3000.123), dec!(1.23456));
1203 assert!(result_eth.is_ok());
1204
1205 let result_fail = converter.normalize_order_for_symbol(
1207 "BTC",
1208 dec!(1.0), dec!(0.001), );
1211 assert!(result_fail.is_err());
1212 assert!(result_fail.unwrap_err().contains("Notional value"));
1213 }
1214
1215 #[rstest]
1216 fn test_hyperliquid_balance_creation_and_properties() {
1217 use rust_decimal_macros::dec;
1218
1219 let asset = "USD".to_string();
1220 let total = dec!(1000.0);
1221 let available = dec!(750.0);
1222 let sequence = 42;
1223 let ts_event = UnixNanos::default();
1224
1225 let balance = HyperliquidBalance::new(asset.clone(), total, available, sequence, ts_event);
1226
1227 assert_eq!(balance.asset, asset);
1228 assert_eq!(balance.total, total);
1229 assert_eq!(balance.available, available);
1230 assert_eq!(balance.sequence, sequence);
1231 assert_eq!(balance.ts_event, ts_event);
1232 assert_eq!(balance.locked(), dec!(250.0)); let full_balance = HyperliquidBalance::new(
1236 "ETH".to_string(),
1237 dec!(100.0),
1238 dec!(100.0),
1239 1,
1240 UnixNanos::default(),
1241 );
1242 assert_eq!(full_balance.locked(), dec!(0.0));
1243
1244 let weird_balance = HyperliquidBalance::new(
1246 "WEIRD".to_string(),
1247 dec!(50.0),
1248 dec!(60.0),
1249 1,
1250 UnixNanos::default(),
1251 );
1252 assert_eq!(weird_balance.locked(), dec!(0.0));
1253 }
1254
1255 #[rstest]
1256 fn test_hyperliquid_account_state_creation() {
1257 let state = HyperliquidAccountState::new();
1258 assert!(state.balances.is_empty());
1259 assert_eq!(state.last_sequence, 0);
1260
1261 let default_state = HyperliquidAccountState::default();
1262 assert!(default_state.balances.is_empty());
1263 assert_eq!(default_state.last_sequence, 0);
1264 }
1265
1266 #[rstest]
1267 fn test_hyperliquid_account_state_getters() {
1268 use rust_decimal_macros::dec;
1269
1270 let mut state = HyperliquidAccountState::new();
1271
1272 let balance = state.get_balance("USD");
1274 assert_eq!(balance.asset, "USD");
1275 assert_eq!(balance.total, dec!(0.0));
1276 assert_eq!(balance.available, dec!(0.0));
1277
1278 let real_balance = HyperliquidBalance::new(
1280 "USD".to_string(),
1281 dec!(1000.0),
1282 dec!(750.0),
1283 1,
1284 UnixNanos::default(),
1285 );
1286 state.balances.insert("USD".to_string(), real_balance);
1287
1288 let retrieved_balance = state.get_balance("USD");
1290 assert_eq!(retrieved_balance.total, dec!(1000.0));
1291 }
1292
1293 #[rstest]
1294 fn test_hyperliquid_account_state_account_value() {
1295 use rust_decimal_macros::dec;
1296
1297 let mut state = HyperliquidAccountState::new();
1298
1299 state.balances.insert(
1301 "USD".to_string(),
1302 HyperliquidBalance::new(
1303 "USD".to_string(),
1304 dec!(10000.0),
1305 dec!(5000.0),
1306 1,
1307 UnixNanos::default(),
1308 ),
1309 );
1310
1311 let total_value = state.account_value();
1312 assert_eq!(total_value, dec!(10000.0));
1313
1314 state.balances.clear();
1316 let no_balance_value = state.account_value();
1317 assert_eq!(no_balance_value, dec!(0.0));
1318 }
1319
1320 #[rstest]
1321 fn test_hyperliquid_account_event_balance_snapshot() {
1322 use rust_decimal_macros::dec;
1323
1324 let mut state = HyperliquidAccountState::new();
1325
1326 let balance = HyperliquidBalance::new(
1327 "USD".to_string(),
1328 dec!(1000.0),
1329 dec!(750.0),
1330 10,
1331 UnixNanos::default(),
1332 );
1333
1334 let snapshot_event = HyperliquidAccountEvent::BalanceSnapshot {
1335 balances: vec![balance],
1336 sequence: 10,
1337 };
1338
1339 state.apply(snapshot_event);
1340
1341 assert_eq!(state.balances.len(), 1);
1342 assert_eq!(state.last_sequence, 10);
1343 assert_eq!(state.get_balance("USD").total, dec!(1000.0));
1344 }
1345
1346 #[rstest]
1347 fn test_hyperliquid_account_event_balance_delta() {
1348 use rust_decimal_macros::dec;
1349
1350 let mut state = HyperliquidAccountState::new();
1351
1352 let initial_balance = HyperliquidBalance::new(
1354 "USD".to_string(),
1355 dec!(1000.0),
1356 dec!(750.0),
1357 5,
1358 UnixNanos::default(),
1359 );
1360 state.balances.insert("USD".to_string(), initial_balance);
1361 state.last_sequence = 5;
1362
1363 let updated_balance = HyperliquidBalance::new(
1365 "USD".to_string(),
1366 dec!(1200.0),
1367 dec!(900.0),
1368 10,
1369 UnixNanos::default(),
1370 );
1371
1372 let delta_event = HyperliquidAccountEvent::BalanceDelta {
1373 balance: updated_balance,
1374 };
1375
1376 state.apply(delta_event);
1377
1378 let balance = state.get_balance("USD");
1379 assert_eq!(balance.total, dec!(1200.0));
1380 assert_eq!(balance.available, dec!(900.0));
1381 assert_eq!(balance.sequence, 10);
1382 assert_eq!(state.last_sequence, 10);
1383
1384 let old_balance = HyperliquidBalance::new(
1386 "USD".to_string(),
1387 dec!(800.0),
1388 dec!(600.0),
1389 8,
1390 UnixNanos::default(),
1391 );
1392
1393 let old_delta_event = HyperliquidAccountEvent::BalanceDelta {
1394 balance: old_balance,
1395 };
1396
1397 state.apply(old_delta_event);
1398
1399 let balance = state.get_balance("USD");
1401 assert_eq!(balance.total, dec!(1200.0)); assert_eq!(balance.sequence, 10); assert_eq!(state.last_sequence, 10); }
1405}