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 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 #[expect(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 AccountBalance::from_total_and_free(balance.total, balance.available, currency)
692 .map_err(anyhow::Error::from)
693 })
694 .collect::<anyhow::Result<Vec<_>>>()?;
695
696 let margins = Vec::new();
698
699 let account_type = AccountType::Margin;
700 let is_reported = true;
701 let event_id = UUID4::new();
702
703 Ok(AccountState::new(
704 account_id,
705 account_type,
706 balances,
707 margins,
708 is_reported,
709 event_id,
710 ts_event,
711 ts_init,
712 None, ))
714 }
715}
716
717#[derive(Debug, Clone)]
727pub enum HyperliquidAccountEvent {
728 BalanceSnapshot {
730 balances: Vec<HyperliquidBalance>,
731 sequence: u64,
732 },
733 BalanceDelta { balance: HyperliquidBalance },
735}
736
737impl HyperliquidAccountState {
738 pub fn apply(&mut self, event: HyperliquidAccountEvent) {
740 match event {
741 HyperliquidAccountEvent::BalanceSnapshot { balances, sequence } => {
742 self.balances.clear();
743
744 for balance in balances {
745 self.balances.insert(balance.asset.clone(), balance);
746 }
747
748 self.last_sequence = sequence;
749 }
750 HyperliquidAccountEvent::BalanceDelta { balance } => {
751 let sequence = balance.sequence;
752 let entry = self
753 .balances
754 .entry(balance.asset.clone())
755 .or_insert_with(|| balance.clone());
756
757 if sequence > entry.sequence {
759 *entry = balance;
760 self.last_sequence = self.last_sequence.max(sequence);
761 }
762 }
763 }
764 }
765}
766
767pub fn parse_position_status_report(
777 position_data: &HyperliquidPositionData,
778 account_id: AccountId,
779 instrument_id: InstrumentId,
780 ts_init: UnixNanos,
781) -> anyhow::Result<PositionStatusReport> {
782 let position_side = if position_data.is_flat() {
784 PositionSide::Flat
785 } else if position_data.is_long() {
786 PositionSide::Long
787 } else {
788 PositionSide::Short
789 };
790
791 let quantity = Quantity::from_decimal(position_data.position.abs())?;
793
794 let ts_last = ts_init;
795 let avg_px_open = position_data.entry_px;
796
797 Ok(PositionStatusReport::new(
798 account_id,
799 instrument_id,
800 position_side.as_specified(),
801 quantity,
802 ts_last,
803 ts_init,
804 None, None, avg_px_open,
807 ))
808}
809
810#[cfg(test)]
811#[allow(dead_code)]
812mod tests {
813 use rstest::rstest;
814 use rust_decimal_macros::dec;
815
816 use super::*;
817 use crate::common::testing::load_test_data;
818
819 fn test_instrument_id() -> InstrumentId {
820 InstrumentId::from("BTC.HYPER")
821 }
822
823 fn sample_http_book() -> HyperliquidL2Book {
824 load_test_data("http_l2_book_snapshot.json")
825 }
826
827 fn sample_ws_book() -> WsBookData {
828 load_test_data("ws_book_data.json")
829 }
830
831 #[rstest]
832 fn test_http_snapshot_conversion() {
833 let converter = HyperliquidDataConverter::new();
834 let book_data = sample_http_book();
835 let instrument_id = test_instrument_id();
836 let ts_init = UnixNanos::default();
837
838 let deltas = converter
839 .convert_http_snapshot(&book_data, instrument_id, ts_init)
840 .unwrap();
841
842 assert_eq!(deltas.instrument_id, instrument_id);
843 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
847 assert_eq!(clear_delta.instrument_id, instrument_id);
848 assert_eq!(clear_delta.action, BookAction::Clear);
849 assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
850 assert_eq!(clear_delta.order.price.raw, 0);
851 assert_eq!(clear_delta.order.price.precision, 0);
852 assert_eq!(clear_delta.order.size.raw, 0);
853 assert_eq!(clear_delta.order.size.precision, 0);
854 assert_eq!(clear_delta.order.order_id, 0);
855 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
856 assert_eq!(clear_delta.sequence, 0);
857 assert_eq!(
858 clear_delta.ts_event,
859 UnixNanos::from(book_data.time * 1_000_000)
860 );
861 assert_eq!(clear_delta.ts_init, ts_init);
862
863 let first_bid_delta = &deltas.deltas[1];
865 assert_eq!(first_bid_delta.instrument_id, instrument_id);
866 assert_eq!(first_bid_delta.action, BookAction::Add);
867 assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
868 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
869 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
870 assert_eq!(first_bid_delta.order.order_id, 1);
871 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
872 assert_eq!(first_bid_delta.sequence, 1);
873 assert_eq!(
874 first_bid_delta.ts_event,
875 UnixNanos::from(book_data.time * 1_000_000)
876 );
877 assert_eq!(first_bid_delta.ts_init, ts_init);
878
879 for delta in &deltas.deltas[1..] {
881 assert_eq!(delta.action, BookAction::Add);
882 assert!(delta.order.size.is_positive());
883 }
884 }
885
886 #[rstest]
887 fn test_ws_snapshot_conversion() {
888 let converter = HyperliquidDataConverter::new();
889 let book_data = sample_ws_book();
890 let instrument_id = test_instrument_id();
891 let ts_init = UnixNanos::default();
892
893 let deltas = converter
894 .convert_ws_snapshot(&book_data, instrument_id, ts_init)
895 .unwrap();
896
897 assert_eq!(deltas.instrument_id, instrument_id);
898 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
902 assert_eq!(clear_delta.instrument_id, instrument_id);
903 assert_eq!(clear_delta.action, BookAction::Clear);
904 assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
905 assert_eq!(clear_delta.order.price.raw, 0);
906 assert_eq!(clear_delta.order.price.precision, 0);
907 assert_eq!(clear_delta.order.size.raw, 0);
908 assert_eq!(clear_delta.order.size.precision, 0);
909 assert_eq!(clear_delta.order.order_id, 0);
910 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
911 assert_eq!(clear_delta.sequence, 0);
912 assert_eq!(
913 clear_delta.ts_event,
914 UnixNanos::from(book_data.time * 1_000_000)
915 );
916 assert_eq!(clear_delta.ts_init, ts_init);
917
918 let first_bid_delta = &deltas.deltas[1];
920 assert_eq!(first_bid_delta.instrument_id, instrument_id);
921 assert_eq!(first_bid_delta.action, BookAction::Add);
922 assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
923 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
924 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
925 assert_eq!(first_bid_delta.order.order_id, 1);
926 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
927 assert_eq!(first_bid_delta.sequence, 1);
928 assert_eq!(
929 first_bid_delta.ts_event,
930 UnixNanos::from(book_data.time * 1_000_000)
931 );
932 assert_eq!(first_bid_delta.ts_init, ts_init);
933 }
934
935 #[rstest]
936 fn test_delta_update_conversion() {
937 let converter = HyperliquidDataConverter::new();
938 let instrument_id = test_instrument_id();
939 let ts_event = UnixNanos::default();
940 let ts_init = UnixNanos::default();
941
942 let bid_updates = vec![("98450.00".to_string(), "1.5".to_string())];
943 let ask_updates = vec![("98451.00".to_string(), "2.0".to_string())];
944 let bid_removals = vec!["98449.00".to_string()];
945 let ask_removals = vec!["98452.00".to_string()];
946
947 let deltas = converter
948 .convert_delta_update(
949 instrument_id,
950 123,
951 ts_event,
952 ts_init,
953 &bid_updates,
954 &ask_updates,
955 &bid_removals,
956 &ask_removals,
957 )
958 .unwrap();
959
960 assert_eq!(deltas.instrument_id, instrument_id);
961 assert_eq!(deltas.deltas.len(), 4); assert_eq!(deltas.sequence, 123);
963
964 let first_delta = &deltas.deltas[0];
966 assert_eq!(first_delta.instrument_id, instrument_id);
967 assert_eq!(first_delta.action, BookAction::Delete);
968 assert_eq!(first_delta.order.side, OrderSide::Buy);
969 assert_eq!(first_delta.order.price, Price::from("98449.00"));
970 assert_eq!(first_delta.order.size, Quantity::from("0"));
971 assert_eq!(first_delta.order.order_id, 123000);
972 assert_eq!(first_delta.flags, 0);
973 assert_eq!(first_delta.sequence, 123);
974 assert_eq!(first_delta.ts_event, ts_event);
975 assert_eq!(first_delta.ts_init, ts_init);
976 }
977
978 #[rstest]
979 fn test_price_size_parsing() {
980 let instrument_id = test_instrument_id();
981 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
982
983 let price = parse_price("98450.50", &config).unwrap();
984 assert_eq!(price.to_string(), "98450.50");
985
986 let size = parse_size("2.5", &config).unwrap();
987 assert_eq!(size.to_string(), "2.5");
988 }
989
990 #[rstest]
991 fn test_hyperliquid_instrument_mini_info() {
992 let instrument_id = test_instrument_id();
993
994 let config = HyperliquidInstrumentInfo::new(instrument_id, 4, 6);
996 assert_eq!(config.instrument_id, instrument_id);
997 assert_eq!(config.price_decimals, 4);
998 assert_eq!(config.size_decimals, 6);
999
1000 let default_config = HyperliquidInstrumentInfo::default_crypto(instrument_id);
1002 assert_eq!(default_config.instrument_id, instrument_id);
1003 assert_eq!(default_config.price_decimals, 2);
1004 assert_eq!(default_config.size_decimals, 5);
1005 }
1006
1007 #[rstest]
1008 fn test_invalid_price_parsing() {
1009 let instrument_id = test_instrument_id();
1010 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1011
1012 let result = parse_price("invalid", &config);
1014 assert!(result.is_err());
1015
1016 match result.unwrap_err() {
1017 ConversionError::InvalidPrice { value } => {
1018 assert_eq!(value, "invalid");
1019 assert!(value.contains("invalid"));
1021 }
1022 _ => panic!("Expected InvalidPrice error"),
1023 }
1024
1025 let size_result = parse_size("not_a_number", &config);
1027 assert!(size_result.is_err());
1028
1029 match size_result.unwrap_err() {
1030 ConversionError::InvalidSize { value } => {
1031 assert_eq!(value, "not_a_number");
1032 assert!(value.contains("not_a_number"));
1034 }
1035 _ => panic!("Expected InvalidSize error"),
1036 }
1037 }
1038
1039 #[rstest]
1040 fn test_configuration() {
1041 let mut converter = HyperliquidDataConverter::new();
1042 let eth_id = InstrumentId::from("ETH.HYPER");
1043 let config = HyperliquidInstrumentInfo::new(eth_id, 4, 8);
1044
1045 let asset = Ustr::from("ETH");
1046
1047 converter.configure_instrument(asset.as_str(), config.clone());
1048
1049 let retrieved_config = converter.get_config(&asset);
1051 assert_eq!(retrieved_config.instrument_id, eth_id);
1052 assert_eq!(retrieved_config.price_decimals, 4);
1053 assert_eq!(retrieved_config.size_decimals, 8);
1054
1055 let default_config = converter.get_config(&Ustr::from("UNKNOWN"));
1057 assert_eq!(
1058 default_config.instrument_id,
1059 InstrumentId::from("UNKNOWN.HYPER")
1060 );
1061 assert_eq!(default_config.price_decimals, 2);
1062 assert_eq!(default_config.size_decimals, 5);
1063
1064 assert_eq!(config.instrument_id, eth_id);
1066 assert_eq!(config.price_decimals, 4);
1067 assert_eq!(config.size_decimals, 8);
1068 }
1069
1070 #[rstest]
1071 fn test_instrument_info_creation() {
1072 let instrument_id = InstrumentId::from("BTC.HYPER");
1073 let info = HyperliquidInstrumentInfo::with_metadata(
1074 instrument_id,
1075 2,
1076 5,
1077 dec!(0.01),
1078 dec!(0.00001),
1079 dec!(10),
1080 );
1081
1082 assert_eq!(info.instrument_id, instrument_id);
1083 assert_eq!(info.price_decimals, 2);
1084 assert_eq!(info.size_decimals, 5);
1085 assert_eq!(info.tick_size, Some(dec!(0.01)));
1086 assert_eq!(info.step_size, Some(dec!(0.00001)));
1087 assert_eq!(info.min_notional, Some(dec!(10)));
1088 }
1089
1090 #[rstest]
1091 fn test_instrument_info_with_precision() {
1092 let instrument_id = test_instrument_id();
1093 let info = HyperliquidInstrumentInfo::with_precision(instrument_id, 3, 4);
1094 assert_eq!(info.instrument_id, instrument_id);
1095 assert_eq!(info.price_decimals, 3);
1096 assert_eq!(info.size_decimals, 4);
1097 assert_eq!(info.tick_size, Some(dec!(0.001))); assert_eq!(info.step_size, Some(dec!(0.0001))); }
1100
1101 #[tokio::test]
1102 async fn test_instrument_cache_basic_operations() {
1103 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1104 InstrumentId::from("BTC.HYPER"),
1105 2,
1106 5,
1107 dec!(0.01),
1108 dec!(0.00001),
1109 dec!(10),
1110 );
1111
1112 let eth_info = HyperliquidInstrumentInfo::with_metadata(
1113 InstrumentId::from("ETH.HYPER"),
1114 2,
1115 4,
1116 dec!(0.01),
1117 dec!(0.0001),
1118 dec!(10),
1119 );
1120
1121 let mut cache = HyperliquidInstrumentCache::new();
1122
1123 cache.insert("BTC", btc_info.clone());
1125 cache.insert("ETH", eth_info.clone());
1126
1127 let retrieved_btc = cache.get("BTC").unwrap();
1129 assert_eq!(retrieved_btc.instrument_id, btc_info.instrument_id);
1130 assert_eq!(retrieved_btc.size_decimals, 5);
1131
1132 let retrieved_eth = cache.get("ETH").unwrap();
1134 assert_eq!(retrieved_eth.instrument_id, eth_info.instrument_id);
1135 assert_eq!(retrieved_eth.size_decimals, 4);
1136
1137 assert_eq!(cache.len(), 2);
1139 assert!(!cache.is_empty());
1140
1141 assert!(cache.contains("BTC"));
1143 assert!(cache.contains("ETH"));
1144 assert!(!cache.contains("UNKNOWN"));
1145
1146 let all_instruments = cache.get_all();
1148 assert_eq!(all_instruments.len(), 2);
1149 }
1150
1151 #[rstest]
1152 fn test_instrument_cache_empty() {
1153 let cache = HyperliquidInstrumentCache::new();
1154 let result = cache.get("UNKNOWN");
1155 assert!(result.is_none());
1156 assert!(cache.is_empty());
1157 assert_eq!(cache.len(), 0);
1158 }
1159
1160 #[rstest]
1161 fn test_normalize_order_for_symbol() {
1162 use rust_decimal_macros::dec;
1163
1164 let mut converter = HyperliquidDataConverter::new();
1165
1166 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1168 InstrumentId::from("BTC.HYPER"),
1169 2,
1170 5,
1171 dec!(0.01), dec!(0.00001), dec!(10.0), );
1175 converter.configure_instrument("BTC", btc_info);
1176
1177 let result = converter.normalize_order_for_symbol(
1179 "BTC",
1180 dec!(50123.456789), dec!(0.123456789), );
1183
1184 assert!(result.is_ok());
1185 let (price, qty) = result.unwrap();
1186 assert_eq!(price, dec!(50123.00));
1188 assert_eq!(qty, dec!(0.12345)); let result_eth = converter.normalize_order_for_symbol("ETH", dec!(3000.123), dec!(1.23456));
1192 assert!(result_eth.is_ok());
1193
1194 let result_fail = converter.normalize_order_for_symbol(
1196 "BTC",
1197 dec!(1.0), dec!(0.001), );
1200 assert!(result_fail.is_err());
1201 assert!(result_fail.unwrap_err().contains("Notional value"));
1202 }
1203
1204 #[rstest]
1205 fn test_hyperliquid_balance_creation_and_properties() {
1206 use rust_decimal_macros::dec;
1207
1208 let asset = "USD".to_string();
1209 let total = dec!(1000.0);
1210 let available = dec!(750.0);
1211 let sequence = 42;
1212 let ts_event = UnixNanos::default();
1213
1214 let balance = HyperliquidBalance::new(asset.clone(), total, available, sequence, ts_event);
1215
1216 assert_eq!(balance.asset, asset);
1217 assert_eq!(balance.total, total);
1218 assert_eq!(balance.available, available);
1219 assert_eq!(balance.sequence, sequence);
1220 assert_eq!(balance.ts_event, ts_event);
1221 assert_eq!(balance.locked(), dec!(250.0)); let full_balance = HyperliquidBalance::new(
1225 "ETH".to_string(),
1226 dec!(100.0),
1227 dec!(100.0),
1228 1,
1229 UnixNanos::default(),
1230 );
1231 assert_eq!(full_balance.locked(), dec!(0.0));
1232
1233 let weird_balance = HyperliquidBalance::new(
1235 "WEIRD".to_string(),
1236 dec!(50.0),
1237 dec!(60.0),
1238 1,
1239 UnixNanos::default(),
1240 );
1241 assert_eq!(weird_balance.locked(), dec!(0.0));
1242 }
1243
1244 #[rstest]
1245 fn test_hyperliquid_account_state_creation() {
1246 let state = HyperliquidAccountState::new();
1247 assert!(state.balances.is_empty());
1248 assert_eq!(state.last_sequence, 0);
1249
1250 let default_state = HyperliquidAccountState::default();
1251 assert!(default_state.balances.is_empty());
1252 assert_eq!(default_state.last_sequence, 0);
1253 }
1254
1255 #[rstest]
1256 fn test_hyperliquid_account_state_getters() {
1257 use rust_decimal_macros::dec;
1258
1259 let mut state = HyperliquidAccountState::new();
1260
1261 let balance = state.get_balance("USD");
1263 assert_eq!(balance.asset, "USD");
1264 assert_eq!(balance.total, dec!(0.0));
1265 assert_eq!(balance.available, dec!(0.0));
1266
1267 let real_balance = HyperliquidBalance::new(
1269 "USD".to_string(),
1270 dec!(1000.0),
1271 dec!(750.0),
1272 1,
1273 UnixNanos::default(),
1274 );
1275 state.balances.insert("USD".to_string(), real_balance);
1276
1277 let retrieved_balance = state.get_balance("USD");
1279 assert_eq!(retrieved_balance.total, dec!(1000.0));
1280 }
1281
1282 #[rstest]
1283 fn test_hyperliquid_account_state_account_value() {
1284 use rust_decimal_macros::dec;
1285
1286 let mut state = HyperliquidAccountState::new();
1287
1288 state.balances.insert(
1290 "USD".to_string(),
1291 HyperliquidBalance::new(
1292 "USD".to_string(),
1293 dec!(10000.0),
1294 dec!(5000.0),
1295 1,
1296 UnixNanos::default(),
1297 ),
1298 );
1299
1300 let total_value = state.account_value();
1301 assert_eq!(total_value, dec!(10000.0));
1302
1303 state.balances.clear();
1305 let no_balance_value = state.account_value();
1306 assert_eq!(no_balance_value, dec!(0.0));
1307 }
1308
1309 #[rstest]
1310 fn test_hyperliquid_account_event_balance_snapshot() {
1311 use rust_decimal_macros::dec;
1312
1313 let mut state = HyperliquidAccountState::new();
1314
1315 let balance = HyperliquidBalance::new(
1316 "USD".to_string(),
1317 dec!(1000.0),
1318 dec!(750.0),
1319 10,
1320 UnixNanos::default(),
1321 );
1322
1323 let snapshot_event = HyperliquidAccountEvent::BalanceSnapshot {
1324 balances: vec![balance],
1325 sequence: 10,
1326 };
1327
1328 state.apply(snapshot_event);
1329
1330 assert_eq!(state.balances.len(), 1);
1331 assert_eq!(state.last_sequence, 10);
1332 assert_eq!(state.get_balance("USD").total, dec!(1000.0));
1333 }
1334
1335 #[rstest]
1336 fn test_hyperliquid_account_event_balance_delta() {
1337 use rust_decimal_macros::dec;
1338
1339 let mut state = HyperliquidAccountState::new();
1340
1341 let initial_balance = HyperliquidBalance::new(
1343 "USD".to_string(),
1344 dec!(1000.0),
1345 dec!(750.0),
1346 5,
1347 UnixNanos::default(),
1348 );
1349 state.balances.insert("USD".to_string(), initial_balance);
1350 state.last_sequence = 5;
1351
1352 let updated_balance = HyperliquidBalance::new(
1354 "USD".to_string(),
1355 dec!(1200.0),
1356 dec!(900.0),
1357 10,
1358 UnixNanos::default(),
1359 );
1360
1361 let delta_event = HyperliquidAccountEvent::BalanceDelta {
1362 balance: updated_balance,
1363 };
1364
1365 state.apply(delta_event);
1366
1367 let balance = state.get_balance("USD");
1368 assert_eq!(balance.total, dec!(1200.0));
1369 assert_eq!(balance.available, dec!(900.0));
1370 assert_eq!(balance.sequence, 10);
1371 assert_eq!(state.last_sequence, 10);
1372
1373 let old_balance = HyperliquidBalance::new(
1375 "USD".to_string(),
1376 dec!(800.0),
1377 dec!(600.0),
1378 8,
1379 UnixNanos::default(),
1380 );
1381
1382 let old_delta_event = HyperliquidAccountEvent::BalanceDelta {
1383 balance: old_balance,
1384 };
1385
1386 state.apply(old_delta_event);
1387
1388 let balance = state.get_balance("USD");
1390 assert_eq!(balance.total, dec!(1200.0)); assert_eq!(balance.sequence, 10); assert_eq!(state.last_sequence, 10); }
1394
1395 #[rstest]
1396 fn test_hyperliquid_account_state_to_account_state_uses_from_total_and_free() {
1397 use nautilus_model::identifiers::AccountId;
1398
1399 let mut state = HyperliquidAccountState::new();
1400 state.balances.insert(
1401 "USDC".to_string(),
1402 HyperliquidBalance::new(
1403 "USDC".to_string(),
1404 dec!(10_000),
1405 dec!(7_500),
1406 1,
1407 UnixNanos::default(),
1408 ),
1409 );
1410 state.balances.insert(
1411 "BTC".to_string(),
1412 HyperliquidBalance::new(
1413 "BTC".to_string(),
1414 dec!(1.25),
1415 dec!(1.0),
1416 2,
1417 UnixNanos::default(),
1418 ),
1419 );
1420
1421 let account_id = AccountId::new("HYPERLIQUID-001");
1422 let ts = UnixNanos::default();
1423 let account_state = state.to_account_state(account_id, ts, ts).unwrap();
1424
1425 assert_eq!(account_state.account_id, account_id);
1426 assert_eq!(account_state.balances.len(), 2);
1427
1428 let usdc = account_state
1429 .balances
1430 .iter()
1431 .find(|b| b.currency.code.as_str() == "USDC")
1432 .expect("USDC balance emitted");
1433 assert_eq!(usdc.total.as_decimal(), dec!(10_000));
1434 assert_eq!(usdc.free.as_decimal(), dec!(7_500));
1435 assert_eq!(usdc.locked.as_decimal(), dec!(2_500));
1436
1437 let btc = account_state
1438 .balances
1439 .iter()
1440 .find(|b| b.currency.code.as_str() == "BTC")
1441 .expect("BTC balance emitted");
1442 assert_eq!(btc.total.as_decimal(), dec!(1.25));
1443 assert_eq!(btc.free.as_decimal(), dec!(1.0));
1444 assert_eq!(btc.locked.as_decimal(), dec!(0.25));
1445 }
1446}