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 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 mut deltas = Vec::new();
252
253 deltas.push(OrderBookDelta::clear(
255 instrument_id,
256 0, UnixNanos::from(data.time * 1_000_000), ts_init,
259 ));
260
261 let mut order_id = 1u64; for level in &data.levels[0] {
265 let (price, size) = parse_level(level, &config)?;
266 if size.is_positive() {
267 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
268 deltas.push(OrderBookDelta::new(
269 instrument_id,
270 BookAction::Add,
271 order,
272 RecordFlag::F_LAST as u8, order_id,
274 UnixNanos::from(data.time * 1_000_000),
275 ts_init,
276 ));
277 order_id += 1;
278 }
279 }
280
281 for level in &data.levels[1] {
283 let (price, size) = parse_level(level, &config)?;
284 if size.is_positive() {
285 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
286 deltas.push(OrderBookDelta::new(
287 instrument_id,
288 BookAction::Add,
289 order,
290 RecordFlag::F_LAST as u8, order_id,
292 UnixNanos::from(data.time * 1_000_000),
293 ts_init,
294 ));
295 order_id += 1;
296 }
297 }
298
299 Ok(OrderBookDeltas::new(instrument_id, deltas))
300 }
301
302 pub fn convert_ws_snapshot(
304 &self,
305 data: &WsBookData,
306 instrument_id: InstrumentId,
307 ts_init: UnixNanos,
308 ) -> Result<OrderBookDeltas, ConversionError> {
309 let config = self.get_config(&data.coin);
310 let mut deltas = Vec::new();
311
312 deltas.push(OrderBookDelta::clear(
314 instrument_id,
315 0, UnixNanos::from(data.time * 1_000_000), ts_init,
318 ));
319
320 let mut order_id = 1u64; for level in &data.levels[0] {
324 let (price, size) = parse_ws_level(level, &config)?;
325 if size.is_positive() {
326 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
327 deltas.push(OrderBookDelta::new(
328 instrument_id,
329 BookAction::Add,
330 order,
331 RecordFlag::F_LAST as u8,
332 order_id,
333 UnixNanos::from(data.time * 1_000_000),
334 ts_init,
335 ));
336 order_id += 1;
337 }
338 }
339
340 for level in &data.levels[1] {
342 let (price, size) = parse_ws_level(level, &config)?;
343 if size.is_positive() {
344 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
345 deltas.push(OrderBookDelta::new(
346 instrument_id,
347 BookAction::Add,
348 order,
349 RecordFlag::F_LAST as u8,
350 order_id,
351 UnixNanos::from(data.time * 1_000_000),
352 ts_init,
353 ));
354 order_id += 1;
355 }
356 }
357
358 Ok(OrderBookDeltas::new(instrument_id, deltas))
359 }
360
361 #[allow(clippy::too_many_arguments)]
364 pub fn convert_delta_update(
365 &self,
366 instrument_id: InstrumentId,
367 sequence: u64,
368 ts_event: UnixNanos,
369 ts_init: UnixNanos,
370 bid_updates: &[(String, String)], ask_updates: &[(String, String)], bid_removals: &[String], ask_removals: &[String], ) -> Result<OrderBookDeltas, ConversionError> {
375 let config = self.get_config(&instrument_id.symbol.inner());
376 let mut deltas = Vec::new();
377 let mut order_id = sequence * 1000; for price_str in bid_removals {
381 let price = parse_price(price_str, &config)?;
382 let order = BookOrder::new(OrderSide::Buy, price, Quantity::from("0"), order_id);
383 deltas.push(OrderBookDelta::new(
384 instrument_id,
385 BookAction::Delete,
386 order,
387 0, sequence,
389 ts_event,
390 ts_init,
391 ));
392 order_id += 1;
393 }
394
395 for price_str in ask_removals {
397 let price = parse_price(price_str, &config)?;
398 let order = BookOrder::new(OrderSide::Sell, price, Quantity::from("0"), order_id);
399 deltas.push(OrderBookDelta::new(
400 instrument_id,
401 BookAction::Delete,
402 order,
403 0, sequence,
405 ts_event,
406 ts_init,
407 ));
408 order_id += 1;
409 }
410
411 for (price_str, size_str) in bid_updates {
413 let price = parse_price(price_str, &config)?;
414 let size = parse_size(size_str, &config)?;
415
416 if size.is_positive() {
417 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
418 deltas.push(OrderBookDelta::new(
419 instrument_id,
420 BookAction::Update, order,
422 0, sequence,
424 ts_event,
425 ts_init,
426 ));
427 } else {
428 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
430 deltas.push(OrderBookDelta::new(
431 instrument_id,
432 BookAction::Delete,
433 order,
434 0, sequence,
436 ts_event,
437 ts_init,
438 ));
439 }
440 order_id += 1;
441 }
442
443 for (price_str, size_str) in ask_updates {
445 let price = parse_price(price_str, &config)?;
446 let size = parse_size(size_str, &config)?;
447
448 if size.is_positive() {
449 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
450 deltas.push(OrderBookDelta::new(
451 instrument_id,
452 BookAction::Update, order,
454 0, sequence,
456 ts_event,
457 ts_init,
458 ));
459 } else {
460 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
462 deltas.push(OrderBookDelta::new(
463 instrument_id,
464 BookAction::Delete,
465 order,
466 0, sequence,
468 ts_event,
469 ts_init,
470 ));
471 }
472 order_id += 1;
473 }
474
475 Ok(OrderBookDeltas::new(instrument_id, deltas))
476 }
477}
478
479fn parse_level(
481 level: &HyperliquidLevel,
482 inst_info: &HyperliquidInstrumentInfo,
483) -> Result<(Price, Quantity), ConversionError> {
484 let price = parse_price(&level.px, inst_info)?;
485 let size = parse_size(&level.sz, inst_info)?;
486 Ok((price, size))
487}
488
489fn parse_ws_level(
491 level: &WsLevelData,
492 config: &HyperliquidInstrumentInfo,
493) -> Result<(Price, Quantity), ConversionError> {
494 let price = parse_price(&level.px, config)?;
495 let size = parse_size(&level.sz, config)?;
496 Ok((price, size))
497}
498
499fn parse_price(
501 price_str: &str,
502 _config: &HyperliquidInstrumentInfo,
503) -> Result<Price, ConversionError> {
504 let _decimal = Decimal::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
505 value: price_str.to_string(),
506 })?;
507
508 Price::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
509 value: price_str.to_string(),
510 })
511}
512
513fn parse_size(
515 size_str: &str,
516 _config: &HyperliquidInstrumentInfo,
517) -> Result<Quantity, ConversionError> {
518 let _decimal = Decimal::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
519 value: size_str.to_string(),
520 })?;
521
522 Quantity::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
523 value: size_str.to_string(),
524 })
525}
526
527#[derive(Debug, Clone, PartialEq, Eq)]
529pub enum ConversionError {
530 InvalidPrice { value: String },
532 InvalidSize { value: String },
534 OrderBookDeltasError(String),
536}
537
538impl From<anyhow::Error> for ConversionError {
539 fn from(err: anyhow::Error) -> Self {
540 Self::OrderBookDeltasError(err.to_string())
541 }
542}
543
544impl Display for ConversionError {
545 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546 match self {
547 Self::InvalidPrice { value } => write!(f, "Invalid price: {value}"),
548 Self::InvalidSize { value } => write!(f, "Invalid size: {value}"),
549 Self::OrderBookDeltasError(msg) => {
550 write!(f, "OrderBookDeltas error: {msg}")
551 }
552 }
553 }
554}
555
556impl std::error::Error for ConversionError {}
557
558#[derive(Clone, Debug)]
567pub struct HyperliquidPositionData {
568 pub asset: String,
569 pub position: Decimal, pub entry_px: Option<Decimal>,
571 pub unrealized_pnl: Decimal,
572 pub cumulative_funding: Option<Decimal>,
573 pub position_value: Decimal,
574}
575
576impl HyperliquidPositionData {
577 pub fn is_flat(&self) -> bool {
579 self.position.is_zero()
580 }
581
582 pub fn is_long(&self) -> bool {
584 self.position > Decimal::ZERO
585 }
586
587 pub fn is_short(&self) -> bool {
589 self.position < Decimal::ZERO
590 }
591}
592
593#[derive(Clone, Debug)]
601pub struct HyperliquidBalance {
602 pub asset: String,
603 pub total: Decimal,
604 pub available: Decimal,
605 pub sequence: u64,
606 pub ts_event: UnixNanos,
607}
608
609impl HyperliquidBalance {
610 pub fn new(
611 asset: String,
612 total: Decimal,
613 available: Decimal,
614 sequence: u64,
615 ts_event: UnixNanos,
616 ) -> Self {
617 Self {
618 asset,
619 total,
620 available,
621 sequence,
622 ts_event,
623 }
624 }
625
626 pub fn locked(&self) -> Decimal {
628 (self.total - self.available).max(Decimal::ZERO)
629 }
630}
631
632#[derive(Default, Debug)]
640pub struct HyperliquidAccountState {
641 pub balances: AHashMap<String, HyperliquidBalance>,
642 pub last_sequence: u64,
643}
644
645impl HyperliquidAccountState {
646 pub fn new() -> Self {
647 Self::default()
648 }
649
650 pub fn get_balance(&self, asset: &str) -> HyperliquidBalance {
652 self.balances.get(asset).cloned().unwrap_or_else(|| {
653 HyperliquidBalance::new(
654 asset.to_string(),
655 Decimal::ZERO,
656 Decimal::ZERO,
657 0,
658 UnixNanos::default(),
659 )
660 })
661 }
662
663 pub fn account_value(&self) -> Decimal {
667 self.balances.values().map(|balance| balance.total).sum()
668 }
669
670 pub fn to_account_state(
679 &self,
680 account_id: AccountId,
681 ts_event: UnixNanos,
682 ts_init: UnixNanos,
683 ) -> anyhow::Result<AccountState> {
684 let balances: Vec<AccountBalance> = self
686 .balances
687 .values()
688 .map(|balance| {
689 let currency = get_currency(&balance.asset);
691
692 let total = Money::from_decimal(balance.total, currency)?;
693 let free = Money::from_decimal(balance.available, currency)?;
694 let locked = total - free;
695
696 Ok(AccountBalance::new(total, locked, free))
697 })
698 .collect::<anyhow::Result<Vec<_>>>()?;
699
700 let margins = Vec::new();
702
703 let account_type = AccountType::Margin;
704 let is_reported = true;
705 let event_id = UUID4::new();
706
707 Ok(AccountState::new(
708 account_id,
709 account_type,
710 balances,
711 margins,
712 is_reported,
713 event_id,
714 ts_event,
715 ts_init,
716 None, ))
718 }
719}
720
721#[derive(Debug, Clone)]
731pub enum HyperliquidAccountEvent {
732 BalanceSnapshot {
734 balances: Vec<HyperliquidBalance>,
735 sequence: u64,
736 },
737 BalanceDelta { balance: HyperliquidBalance },
739}
740
741impl HyperliquidAccountState {
742 pub fn apply(&mut self, event: HyperliquidAccountEvent) {
744 match event {
745 HyperliquidAccountEvent::BalanceSnapshot { balances, sequence } => {
746 self.balances.clear();
747
748 for balance in balances {
749 self.balances.insert(balance.asset.clone(), balance);
750 }
751
752 self.last_sequence = sequence;
753 }
754 HyperliquidAccountEvent::BalanceDelta { balance } => {
755 let sequence = balance.sequence;
756 let entry = self
757 .balances
758 .entry(balance.asset.clone())
759 .or_insert_with(|| balance.clone());
760
761 if sequence > entry.sequence {
763 *entry = balance;
764 self.last_sequence = self.last_sequence.max(sequence);
765 }
766 }
767 }
768 }
769}
770
771pub fn parse_position_status_report(
781 position_data: &HyperliquidPositionData,
782 account_id: AccountId,
783 instrument_id: InstrumentId,
784 ts_init: UnixNanos,
785) -> anyhow::Result<PositionStatusReport> {
786 let position_side = if position_data.is_flat() {
788 PositionSide::Flat
789 } else if position_data.is_long() {
790 PositionSide::Long
791 } else {
792 PositionSide::Short
793 };
794
795 let quantity = Quantity::from_decimal(position_data.position.abs())?;
797
798 let ts_last = ts_init;
799 let avg_px_open = position_data.entry_px;
800
801 Ok(PositionStatusReport::new(
802 account_id,
803 instrument_id,
804 position_side.as_specified(),
805 quantity,
806 ts_last,
807 ts_init,
808 None, None, avg_px_open,
811 ))
812}
813
814#[cfg(test)]
815#[allow(dead_code)]
816mod tests {
817 use rstest::rstest;
818 use rust_decimal_macros::dec;
819
820 use super::*;
821 use crate::common::testing::load_test_data;
822
823 fn test_instrument_id() -> InstrumentId {
824 InstrumentId::from("BTC.HYPER")
825 }
826
827 fn sample_http_book() -> HyperliquidL2Book {
828 load_test_data("http_l2_book_snapshot.json")
829 }
830
831 fn sample_ws_book() -> WsBookData {
832 load_test_data("ws_book_data.json")
833 }
834
835 #[rstest]
836 fn test_http_snapshot_conversion() {
837 let converter = HyperliquidDataConverter::new();
838 let book_data = sample_http_book();
839 let instrument_id = test_instrument_id();
840 let ts_init = UnixNanos::default();
841
842 let deltas = converter
843 .convert_http_snapshot(&book_data, instrument_id, ts_init)
844 .unwrap();
845
846 assert_eq!(deltas.instrument_id, instrument_id);
847 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
851 assert_eq!(clear_delta.instrument_id, instrument_id);
852 assert_eq!(clear_delta.action, BookAction::Clear);
853 assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
854 assert_eq!(clear_delta.order.price.raw, 0);
855 assert_eq!(clear_delta.order.price.precision, 0);
856 assert_eq!(clear_delta.order.size.raw, 0);
857 assert_eq!(clear_delta.order.size.precision, 0);
858 assert_eq!(clear_delta.order.order_id, 0);
859 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
860 assert_eq!(clear_delta.sequence, 0);
861 assert_eq!(
862 clear_delta.ts_event,
863 UnixNanos::from(book_data.time * 1_000_000)
864 );
865 assert_eq!(clear_delta.ts_init, ts_init);
866
867 let first_bid_delta = &deltas.deltas[1];
869 assert_eq!(first_bid_delta.instrument_id, instrument_id);
870 assert_eq!(first_bid_delta.action, BookAction::Add);
871 assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
872 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
873 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
874 assert_eq!(first_bid_delta.order.order_id, 1);
875 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
876 assert_eq!(first_bid_delta.sequence, 1);
877 assert_eq!(
878 first_bid_delta.ts_event,
879 UnixNanos::from(book_data.time * 1_000_000)
880 );
881 assert_eq!(first_bid_delta.ts_init, ts_init);
882
883 for delta in &deltas.deltas[1..] {
885 assert_eq!(delta.action, BookAction::Add);
886 assert!(delta.order.size.is_positive());
887 }
888 }
889
890 #[rstest]
891 fn test_ws_snapshot_conversion() {
892 let converter = HyperliquidDataConverter::new();
893 let book_data = sample_ws_book();
894 let instrument_id = test_instrument_id();
895 let ts_init = UnixNanos::default();
896
897 let deltas = converter
898 .convert_ws_snapshot(&book_data, instrument_id, ts_init)
899 .unwrap();
900
901 assert_eq!(deltas.instrument_id, instrument_id);
902 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
906 assert_eq!(clear_delta.instrument_id, instrument_id);
907 assert_eq!(clear_delta.action, BookAction::Clear);
908 assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
909 assert_eq!(clear_delta.order.price.raw, 0);
910 assert_eq!(clear_delta.order.price.precision, 0);
911 assert_eq!(clear_delta.order.size.raw, 0);
912 assert_eq!(clear_delta.order.size.precision, 0);
913 assert_eq!(clear_delta.order.order_id, 0);
914 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
915 assert_eq!(clear_delta.sequence, 0);
916 assert_eq!(
917 clear_delta.ts_event,
918 UnixNanos::from(book_data.time * 1_000_000)
919 );
920 assert_eq!(clear_delta.ts_init, ts_init);
921
922 let first_bid_delta = &deltas.deltas[1];
924 assert_eq!(first_bid_delta.instrument_id, instrument_id);
925 assert_eq!(first_bid_delta.action, BookAction::Add);
926 assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
927 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
928 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
929 assert_eq!(first_bid_delta.order.order_id, 1);
930 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
931 assert_eq!(first_bid_delta.sequence, 1);
932 assert_eq!(
933 first_bid_delta.ts_event,
934 UnixNanos::from(book_data.time * 1_000_000)
935 );
936 assert_eq!(first_bid_delta.ts_init, ts_init);
937 }
938
939 #[rstest]
940 fn test_delta_update_conversion() {
941 let converter = HyperliquidDataConverter::new();
942 let instrument_id = test_instrument_id();
943 let ts_event = UnixNanos::default();
944 let ts_init = UnixNanos::default();
945
946 let bid_updates = vec![("98450.00".to_string(), "1.5".to_string())];
947 let ask_updates = vec![("98451.00".to_string(), "2.0".to_string())];
948 let bid_removals = vec!["98449.00".to_string()];
949 let ask_removals = vec!["98452.00".to_string()];
950
951 let deltas = converter
952 .convert_delta_update(
953 instrument_id,
954 123,
955 ts_event,
956 ts_init,
957 &bid_updates,
958 &ask_updates,
959 &bid_removals,
960 &ask_removals,
961 )
962 .unwrap();
963
964 assert_eq!(deltas.instrument_id, instrument_id);
965 assert_eq!(deltas.deltas.len(), 4); assert_eq!(deltas.sequence, 123);
967
968 let first_delta = &deltas.deltas[0];
970 assert_eq!(first_delta.instrument_id, instrument_id);
971 assert_eq!(first_delta.action, BookAction::Delete);
972 assert_eq!(first_delta.order.side, OrderSide::Buy);
973 assert_eq!(first_delta.order.price, Price::from("98449.00"));
974 assert_eq!(first_delta.order.size, Quantity::from("0"));
975 assert_eq!(first_delta.order.order_id, 123000);
976 assert_eq!(first_delta.flags, 0);
977 assert_eq!(first_delta.sequence, 123);
978 assert_eq!(first_delta.ts_event, ts_event);
979 assert_eq!(first_delta.ts_init, ts_init);
980 }
981
982 #[rstest]
983 fn test_price_size_parsing() {
984 let instrument_id = test_instrument_id();
985 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
986
987 let price = parse_price("98450.50", &config).unwrap();
988 assert_eq!(price.to_string(), "98450.50");
989
990 let size = parse_size("2.5", &config).unwrap();
991 assert_eq!(size.to_string(), "2.5");
992 }
993
994 #[rstest]
995 fn test_hyperliquid_instrument_mini_info() {
996 let instrument_id = test_instrument_id();
997
998 let config = HyperliquidInstrumentInfo::new(instrument_id, 4, 6);
1000 assert_eq!(config.instrument_id, instrument_id);
1001 assert_eq!(config.price_decimals, 4);
1002 assert_eq!(config.size_decimals, 6);
1003
1004 let default_config = HyperliquidInstrumentInfo::default_crypto(instrument_id);
1006 assert_eq!(default_config.instrument_id, instrument_id);
1007 assert_eq!(default_config.price_decimals, 2);
1008 assert_eq!(default_config.size_decimals, 5);
1009 }
1010
1011 #[rstest]
1012 fn test_invalid_price_parsing() {
1013 let instrument_id = test_instrument_id();
1014 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1015
1016 let result = parse_price("invalid", &config);
1018 assert!(result.is_err());
1019
1020 match result.unwrap_err() {
1021 ConversionError::InvalidPrice { value } => {
1022 assert_eq!(value, "invalid");
1023 assert!(value.contains("invalid"));
1025 }
1026 _ => panic!("Expected InvalidPrice error"),
1027 }
1028
1029 let size_result = parse_size("not_a_number", &config);
1031 assert!(size_result.is_err());
1032
1033 match size_result.unwrap_err() {
1034 ConversionError::InvalidSize { value } => {
1035 assert_eq!(value, "not_a_number");
1036 assert!(value.contains("not_a_number"));
1038 }
1039 _ => panic!("Expected InvalidSize error"),
1040 }
1041 }
1042
1043 #[rstest]
1044 fn test_configuration() {
1045 let mut converter = HyperliquidDataConverter::new();
1046 let eth_id = InstrumentId::from("ETH.HYPER");
1047 let config = HyperliquidInstrumentInfo::new(eth_id, 4, 8);
1048
1049 let asset = Ustr::from("ETH");
1050
1051 converter.configure_instrument(asset.as_str(), config.clone());
1052
1053 let retrieved_config = converter.get_config(&asset);
1055 assert_eq!(retrieved_config.instrument_id, eth_id);
1056 assert_eq!(retrieved_config.price_decimals, 4);
1057 assert_eq!(retrieved_config.size_decimals, 8);
1058
1059 let default_config = converter.get_config(&Ustr::from("UNKNOWN"));
1061 assert_eq!(
1062 default_config.instrument_id,
1063 InstrumentId::from("UNKNOWN.HYPER")
1064 );
1065 assert_eq!(default_config.price_decimals, 2);
1066 assert_eq!(default_config.size_decimals, 5);
1067
1068 assert_eq!(config.instrument_id, eth_id);
1070 assert_eq!(config.price_decimals, 4);
1071 assert_eq!(config.size_decimals, 8);
1072 }
1073
1074 #[rstest]
1075 fn test_instrument_info_creation() {
1076 let instrument_id = InstrumentId::from("BTC.HYPER");
1077 let info = HyperliquidInstrumentInfo::with_metadata(
1078 instrument_id,
1079 2,
1080 5,
1081 dec!(0.01),
1082 dec!(0.00001),
1083 dec!(10),
1084 );
1085
1086 assert_eq!(info.instrument_id, instrument_id);
1087 assert_eq!(info.price_decimals, 2);
1088 assert_eq!(info.size_decimals, 5);
1089 assert_eq!(info.tick_size, Some(dec!(0.01)));
1090 assert_eq!(info.step_size, Some(dec!(0.00001)));
1091 assert_eq!(info.min_notional, Some(dec!(10)));
1092 }
1093
1094 #[rstest]
1095 fn test_instrument_info_with_precision() {
1096 let instrument_id = test_instrument_id();
1097 let info = HyperliquidInstrumentInfo::with_precision(instrument_id, 3, 4);
1098 assert_eq!(info.instrument_id, instrument_id);
1099 assert_eq!(info.price_decimals, 3);
1100 assert_eq!(info.size_decimals, 4);
1101 assert_eq!(info.tick_size, Some(dec!(0.001))); assert_eq!(info.step_size, Some(dec!(0.0001))); }
1104
1105 #[tokio::test]
1106 async fn test_instrument_cache_basic_operations() {
1107 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1108 InstrumentId::from("BTC.HYPER"),
1109 2,
1110 5,
1111 dec!(0.01),
1112 dec!(0.00001),
1113 dec!(10),
1114 );
1115
1116 let eth_info = HyperliquidInstrumentInfo::with_metadata(
1117 InstrumentId::from("ETH.HYPER"),
1118 2,
1119 4,
1120 dec!(0.01),
1121 dec!(0.0001),
1122 dec!(10),
1123 );
1124
1125 let mut cache = HyperliquidInstrumentCache::new();
1126
1127 cache.insert("BTC", btc_info.clone());
1129 cache.insert("ETH", eth_info.clone());
1130
1131 let retrieved_btc = cache.get("BTC").unwrap();
1133 assert_eq!(retrieved_btc.instrument_id, btc_info.instrument_id);
1134 assert_eq!(retrieved_btc.size_decimals, 5);
1135
1136 let retrieved_eth = cache.get("ETH").unwrap();
1138 assert_eq!(retrieved_eth.instrument_id, eth_info.instrument_id);
1139 assert_eq!(retrieved_eth.size_decimals, 4);
1140
1141 assert_eq!(cache.len(), 2);
1143 assert!(!cache.is_empty());
1144
1145 assert!(cache.contains("BTC"));
1147 assert!(cache.contains("ETH"));
1148 assert!(!cache.contains("UNKNOWN"));
1149
1150 let all_instruments = cache.get_all();
1152 assert_eq!(all_instruments.len(), 2);
1153 }
1154
1155 #[rstest]
1156 fn test_instrument_cache_empty() {
1157 let cache = HyperliquidInstrumentCache::new();
1158 let result = cache.get("UNKNOWN");
1159 assert!(result.is_none());
1160 assert!(cache.is_empty());
1161 assert_eq!(cache.len(), 0);
1162 }
1163
1164 #[rstest]
1165 fn test_normalize_order_for_symbol() {
1166 use rust_decimal_macros::dec;
1167
1168 let mut converter = HyperliquidDataConverter::new();
1169
1170 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1172 InstrumentId::from("BTC.HYPER"),
1173 2,
1174 5,
1175 dec!(0.01), dec!(0.00001), dec!(10.0), );
1179 converter.configure_instrument("BTC", btc_info);
1180
1181 let result = converter.normalize_order_for_symbol(
1183 "BTC",
1184 dec!(50123.456789), dec!(0.123456789), );
1187
1188 assert!(result.is_ok());
1189 let (price, qty) = result.unwrap();
1190 assert_eq!(price, dec!(50123.00));
1192 assert_eq!(qty, dec!(0.12345)); let result_eth = converter.normalize_order_for_symbol("ETH", dec!(3000.123), dec!(1.23456));
1196 assert!(result_eth.is_ok());
1197
1198 let result_fail = converter.normalize_order_for_symbol(
1200 "BTC",
1201 dec!(1.0), dec!(0.001), );
1204 assert!(result_fail.is_err());
1205 assert!(result_fail.unwrap_err().contains("Notional value"));
1206 }
1207
1208 #[rstest]
1209 fn test_hyperliquid_balance_creation_and_properties() {
1210 use rust_decimal_macros::dec;
1211
1212 let asset = "USD".to_string();
1213 let total = dec!(1000.0);
1214 let available = dec!(750.0);
1215 let sequence = 42;
1216 let ts_event = UnixNanos::default();
1217
1218 let balance = HyperliquidBalance::new(asset.clone(), total, available, sequence, ts_event);
1219
1220 assert_eq!(balance.asset, asset);
1221 assert_eq!(balance.total, total);
1222 assert_eq!(balance.available, available);
1223 assert_eq!(balance.sequence, sequence);
1224 assert_eq!(balance.ts_event, ts_event);
1225 assert_eq!(balance.locked(), dec!(250.0)); let full_balance = HyperliquidBalance::new(
1229 "ETH".to_string(),
1230 dec!(100.0),
1231 dec!(100.0),
1232 1,
1233 UnixNanos::default(),
1234 );
1235 assert_eq!(full_balance.locked(), dec!(0.0));
1236
1237 let weird_balance = HyperliquidBalance::new(
1239 "WEIRD".to_string(),
1240 dec!(50.0),
1241 dec!(60.0),
1242 1,
1243 UnixNanos::default(),
1244 );
1245 assert_eq!(weird_balance.locked(), dec!(0.0));
1246 }
1247
1248 #[rstest]
1249 fn test_hyperliquid_account_state_creation() {
1250 let state = HyperliquidAccountState::new();
1251 assert!(state.balances.is_empty());
1252 assert_eq!(state.last_sequence, 0);
1253
1254 let default_state = HyperliquidAccountState::default();
1255 assert!(default_state.balances.is_empty());
1256 assert_eq!(default_state.last_sequence, 0);
1257 }
1258
1259 #[rstest]
1260 fn test_hyperliquid_account_state_getters() {
1261 use rust_decimal_macros::dec;
1262
1263 let mut state = HyperliquidAccountState::new();
1264
1265 let balance = state.get_balance("USD");
1267 assert_eq!(balance.asset, "USD");
1268 assert_eq!(balance.total, dec!(0.0));
1269 assert_eq!(balance.available, dec!(0.0));
1270
1271 let real_balance = HyperliquidBalance::new(
1273 "USD".to_string(),
1274 dec!(1000.0),
1275 dec!(750.0),
1276 1,
1277 UnixNanos::default(),
1278 );
1279 state.balances.insert("USD".to_string(), real_balance);
1280
1281 let retrieved_balance = state.get_balance("USD");
1283 assert_eq!(retrieved_balance.total, dec!(1000.0));
1284 }
1285
1286 #[rstest]
1287 fn test_hyperliquid_account_state_account_value() {
1288 use rust_decimal_macros::dec;
1289
1290 let mut state = HyperliquidAccountState::new();
1291
1292 state.balances.insert(
1294 "USD".to_string(),
1295 HyperliquidBalance::new(
1296 "USD".to_string(),
1297 dec!(10000.0),
1298 dec!(5000.0),
1299 1,
1300 UnixNanos::default(),
1301 ),
1302 );
1303
1304 let total_value = state.account_value();
1305 assert_eq!(total_value, dec!(10000.0));
1306
1307 state.balances.clear();
1309 let no_balance_value = state.account_value();
1310 assert_eq!(no_balance_value, dec!(0.0));
1311 }
1312
1313 #[rstest]
1314 fn test_hyperliquid_account_event_balance_snapshot() {
1315 use rust_decimal_macros::dec;
1316
1317 let mut state = HyperliquidAccountState::new();
1318
1319 let balance = HyperliquidBalance::new(
1320 "USD".to_string(),
1321 dec!(1000.0),
1322 dec!(750.0),
1323 10,
1324 UnixNanos::default(),
1325 );
1326
1327 let snapshot_event = HyperliquidAccountEvent::BalanceSnapshot {
1328 balances: vec![balance],
1329 sequence: 10,
1330 };
1331
1332 state.apply(snapshot_event);
1333
1334 assert_eq!(state.balances.len(), 1);
1335 assert_eq!(state.last_sequence, 10);
1336 assert_eq!(state.get_balance("USD").total, dec!(1000.0));
1337 }
1338
1339 #[rstest]
1340 fn test_hyperliquid_account_event_balance_delta() {
1341 use rust_decimal_macros::dec;
1342
1343 let mut state = HyperliquidAccountState::new();
1344
1345 let initial_balance = HyperliquidBalance::new(
1347 "USD".to_string(),
1348 dec!(1000.0),
1349 dec!(750.0),
1350 5,
1351 UnixNanos::default(),
1352 );
1353 state.balances.insert("USD".to_string(), initial_balance);
1354 state.last_sequence = 5;
1355
1356 let updated_balance = HyperliquidBalance::new(
1358 "USD".to_string(),
1359 dec!(1200.0),
1360 dec!(900.0),
1361 10,
1362 UnixNanos::default(),
1363 );
1364
1365 let delta_event = HyperliquidAccountEvent::BalanceDelta {
1366 balance: updated_balance,
1367 };
1368
1369 state.apply(delta_event);
1370
1371 let balance = state.get_balance("USD");
1372 assert_eq!(balance.total, dec!(1200.0));
1373 assert_eq!(balance.available, dec!(900.0));
1374 assert_eq!(balance.sequence, 10);
1375 assert_eq!(state.last_sequence, 10);
1376
1377 let old_balance = HyperliquidBalance::new(
1379 "USD".to_string(),
1380 dec!(800.0),
1381 dec!(600.0),
1382 8,
1383 UnixNanos::default(),
1384 );
1385
1386 let old_delta_event = HyperliquidAccountEvent::BalanceDelta {
1387 balance: old_balance,
1388 };
1389
1390 state.apply(old_delta_event);
1391
1392 let balance = state.get_balance("USD");
1394 assert_eq!(balance.total, dec!(1200.0)); assert_eq!(balance.sequence, 10); assert_eq!(state.last_sequence, 10); }
1398}