1use std::{collections::HashMap, fmt::Display, str::FromStr};
17
18use nautilus_core::{UUID4, UnixNanos};
19use nautilus_model::{
20 data::{delta::OrderBookDelta, deltas::OrderBookDeltas, order::BookOrder},
21 enums::{AccountType, BookAction, OrderSide, PositionSide, RecordFlag},
22 events::AccountState,
23 identifiers::{AccountId, InstrumentId},
24 reports::PositionStatusReport,
25 types::{AccountBalance, Money, Price, Quantity},
26};
27use rust_decimal::{Decimal, prelude::ToPrimitive};
28use ustr::Ustr;
29
30use crate::{
31 http::{
32 models::{HyperliquidL2Book, HyperliquidLevel},
33 parse::get_currency,
34 },
35 websocket::messages::{WsBookData, WsLevelData},
36};
37
38#[derive(Debug, Clone)]
40pub struct HyperliquidInstrumentInfo {
41 pub instrument_id: InstrumentId,
42 pub price_decimals: u8,
43 pub size_decimals: u8,
44 pub tick_size: Option<Decimal>,
46 pub step_size: Option<Decimal>,
48 pub min_notional: Option<Decimal>,
50}
51
52impl HyperliquidInstrumentInfo {
53 pub fn new(instrument_id: InstrumentId, price_decimals: u8, size_decimals: u8) -> Self {
55 Self {
56 instrument_id,
57 price_decimals,
58 size_decimals,
59 tick_size: None,
60 step_size: None,
61 min_notional: None,
62 }
63 }
64
65 pub fn with_metadata(
67 instrument_id: InstrumentId,
68 price_decimals: u8,
69 size_decimals: u8,
70 tick_size: Decimal,
71 step_size: Decimal,
72 min_notional: Decimal,
73 ) -> Self {
74 Self {
75 instrument_id,
76 price_decimals,
77 size_decimals,
78 tick_size: Some(tick_size),
79 step_size: Some(step_size),
80 min_notional: Some(min_notional),
81 }
82 }
83
84 pub fn with_precision(
86 instrument_id: InstrumentId,
87 price_decimals: u8,
88 size_decimals: u8,
89 ) -> Self {
90 let tick_size = Decimal::new(1, price_decimals as u32);
91 let step_size = Decimal::new(1, size_decimals as u32);
92 Self {
93 instrument_id,
94 price_decimals,
95 size_decimals,
96 tick_size: Some(tick_size),
97 step_size: Some(step_size),
98 min_notional: None,
99 }
100 }
101
102 pub fn default_crypto(instrument_id: InstrumentId) -> Self {
104 Self::with_precision(instrument_id, 2, 5) }
106}
107
108#[derive(Debug, Default)]
110pub struct HyperliquidInstrumentCache {
111 instruments_by_symbol: HashMap<Ustr, HyperliquidInstrumentInfo>,
112}
113
114impl HyperliquidInstrumentCache {
115 pub fn new() -> Self {
117 Self {
118 instruments_by_symbol: HashMap::new(),
119 }
120 }
121
122 pub fn insert(&mut self, symbol: &str, info: HyperliquidInstrumentInfo) {
124 self.instruments_by_symbol.insert(Ustr::from(symbol), info);
125 }
126
127 pub fn get(&self, symbol: &str) -> Option<&HyperliquidInstrumentInfo> {
129 self.instruments_by_symbol.get(&Ustr::from(symbol))
130 }
131
132 pub fn get_all(&self) -> Vec<&HyperliquidInstrumentInfo> {
134 self.instruments_by_symbol.values().collect()
135 }
136
137 pub fn contains(&self, symbol: &str) -> bool {
139 self.instruments_by_symbol.contains_key(&Ustr::from(symbol))
140 }
141
142 pub fn len(&self) -> usize {
144 self.instruments_by_symbol.len()
145 }
146
147 pub fn is_empty(&self) -> bool {
149 self.instruments_by_symbol.is_empty()
150 }
151
152 pub fn clear(&mut self) {
154 self.instruments_by_symbol.clear();
155 }
156}
157
158#[derive(Clone, Debug, PartialEq, Eq, Hash)]
160pub enum HyperliquidTradeKey {
161 Id(String),
163 Seq(u64),
165}
166
167#[derive(Debug)]
169pub struct HyperliquidDataConverter {
170 configs: HashMap<Ustr, HyperliquidInstrumentInfo>,
172}
173
174impl Default for HyperliquidDataConverter {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180impl HyperliquidDataConverter {
181 pub fn new() -> Self {
183 Self {
184 configs: HashMap::new(),
185 }
186 }
187
188 pub fn normalize_order_for_symbol(
193 &mut self,
194 symbol: &str,
195 price: Decimal,
196 qty: Decimal,
197 ) -> Result<(Decimal, Decimal), String> {
198 let config = self.get_config(&Ustr::from(symbol));
199
200 let tick_size = config.tick_size.unwrap_or_else(|| Decimal::new(1, 2)); let step_size = config.step_size.unwrap_or_else(|| {
203 match config.size_decimals {
205 0 => Decimal::ONE,
206 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), }
213 });
214 let min_notional = config.min_notional.unwrap_or_else(|| Decimal::from(10)); crate::common::parse::normalize_order(
217 price,
218 qty,
219 tick_size,
220 step_size,
221 min_notional,
222 config.price_decimals,
223 config.size_decimals,
224 )
225 }
226
227 pub fn configure_instrument(&mut self, symbol: &str, config: HyperliquidInstrumentInfo) {
229 self.configs.insert(Ustr::from(symbol), config);
230 }
231
232 fn get_config(&self, symbol: &Ustr) -> HyperliquidInstrumentInfo {
234 self.configs.get(symbol).cloned().unwrap_or_else(|| {
235 let instrument_id = InstrumentId::from(format!("{symbol}.HYPER").as_str());
237 HyperliquidInstrumentInfo::default_crypto(instrument_id)
238 })
239 }
240
241 pub fn convert_http_snapshot(
243 &self,
244 data: &HyperliquidL2Book,
245 instrument_id: InstrumentId,
246 ts_init: UnixNanos,
247 ) -> Result<OrderBookDeltas, ConversionError> {
248 let config = self.get_config(&data.coin);
249 let mut deltas = Vec::new();
250
251 deltas.push(OrderBookDelta::clear(
253 instrument_id,
254 0, UnixNanos::from(data.time * 1_000_000), ts_init,
257 ));
258
259 let mut order_id = 1u64; for level in &data.levels[0] {
263 let (price, size) = parse_level(level, &config)?;
264 if size.is_positive() {
265 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
266 deltas.push(OrderBookDelta::new(
267 instrument_id,
268 BookAction::Add,
269 order,
270 RecordFlag::F_LAST as u8, order_id,
272 UnixNanos::from(data.time * 1_000_000),
273 ts_init,
274 ));
275 order_id += 1;
276 }
277 }
278
279 for level in &data.levels[1] {
281 let (price, size) = parse_level(level, &config)?;
282 if size.is_positive() {
283 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
284 deltas.push(OrderBookDelta::new(
285 instrument_id,
286 BookAction::Add,
287 order,
288 RecordFlag::F_LAST as u8, order_id,
290 UnixNanos::from(data.time * 1_000_000),
291 ts_init,
292 ));
293 order_id += 1;
294 }
295 }
296
297 Ok(OrderBookDeltas::new(instrument_id, deltas))
298 }
299
300 pub fn convert_ws_snapshot(
302 &self,
303 data: &WsBookData,
304 instrument_id: InstrumentId,
305 ts_init: UnixNanos,
306 ) -> Result<OrderBookDeltas, ConversionError> {
307 let config = self.get_config(&data.coin);
308 let mut deltas = Vec::new();
309
310 deltas.push(OrderBookDelta::clear(
312 instrument_id,
313 0, UnixNanos::from(data.time * 1_000_000), ts_init,
316 ));
317
318 let mut order_id = 1u64; for level in &data.levels[0] {
322 let (price, size) = parse_ws_level(level, &config)?;
323 if size.is_positive() {
324 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
325 deltas.push(OrderBookDelta::new(
326 instrument_id,
327 BookAction::Add,
328 order,
329 RecordFlag::F_LAST as u8,
330 order_id,
331 UnixNanos::from(data.time * 1_000_000),
332 ts_init,
333 ));
334 order_id += 1;
335 }
336 }
337
338 for level in &data.levels[1] {
340 let (price, size) = parse_ws_level(level, &config)?;
341 if size.is_positive() {
342 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
343 deltas.push(OrderBookDelta::new(
344 instrument_id,
345 BookAction::Add,
346 order,
347 RecordFlag::F_LAST as u8,
348 order_id,
349 UnixNanos::from(data.time * 1_000_000),
350 ts_init,
351 ));
352 order_id += 1;
353 }
354 }
355
356 Ok(OrderBookDeltas::new(instrument_id, deltas))
357 }
358
359 #[allow(clippy::too_many_arguments)]
362 pub fn convert_delta_update(
363 &self,
364 instrument_id: InstrumentId,
365 sequence: u64,
366 ts_event: UnixNanos,
367 ts_init: UnixNanos,
368 bid_updates: &[(String, String)], ask_updates: &[(String, String)], bid_removals: &[String], ask_removals: &[String], ) -> Result<OrderBookDeltas, ConversionError> {
373 let config = self.get_config(&instrument_id.symbol.inner());
374 let mut deltas = Vec::new();
375 let mut order_id = sequence * 1000; for price_str in bid_removals {
379 let price = parse_price(price_str, &config)?;
380 let order = BookOrder::new(OrderSide::Buy, price, Quantity::from("0"), order_id);
381 deltas.push(OrderBookDelta::new(
382 instrument_id,
383 BookAction::Delete,
384 order,
385 0, sequence,
387 ts_event,
388 ts_init,
389 ));
390 order_id += 1;
391 }
392
393 for price_str in ask_removals {
395 let price = parse_price(price_str, &config)?;
396 let order = BookOrder::new(OrderSide::Sell, price, Quantity::from("0"), order_id);
397 deltas.push(OrderBookDelta::new(
398 instrument_id,
399 BookAction::Delete,
400 order,
401 0, sequence,
403 ts_event,
404 ts_init,
405 ));
406 order_id += 1;
407 }
408
409 for (price_str, size_str) in bid_updates {
411 let price = parse_price(price_str, &config)?;
412 let size = parse_size(size_str, &config)?;
413
414 if size.is_positive() {
415 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
416 deltas.push(OrderBookDelta::new(
417 instrument_id,
418 BookAction::Update, order,
420 0, sequence,
422 ts_event,
423 ts_init,
424 ));
425 } else {
426 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
428 deltas.push(OrderBookDelta::new(
429 instrument_id,
430 BookAction::Delete,
431 order,
432 0, sequence,
434 ts_event,
435 ts_init,
436 ));
437 }
438 order_id += 1;
439 }
440
441 for (price_str, size_str) in ask_updates {
443 let price = parse_price(price_str, &config)?;
444 let size = parse_size(size_str, &config)?;
445
446 if size.is_positive() {
447 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
448 deltas.push(OrderBookDelta::new(
449 instrument_id,
450 BookAction::Update, order,
452 0, sequence,
454 ts_event,
455 ts_init,
456 ));
457 } else {
458 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
460 deltas.push(OrderBookDelta::new(
461 instrument_id,
462 BookAction::Delete,
463 order,
464 0, sequence,
466 ts_event,
467 ts_init,
468 ));
469 }
470 order_id += 1;
471 }
472
473 Ok(OrderBookDeltas::new(instrument_id, deltas))
474 }
475}
476
477fn parse_level(
479 level: &HyperliquidLevel,
480 inst_info: &HyperliquidInstrumentInfo,
481) -> Result<(Price, Quantity), ConversionError> {
482 let price = parse_price(&level.px, inst_info)?;
483 let size = parse_size(&level.sz, inst_info)?;
484 Ok((price, size))
485}
486
487fn parse_ws_level(
489 level: &WsLevelData,
490 config: &HyperliquidInstrumentInfo,
491) -> Result<(Price, Quantity), ConversionError> {
492 let price = parse_price(&level.px, config)?;
493 let size = parse_size(&level.sz, config)?;
494 Ok((price, size))
495}
496
497fn parse_price(
499 price_str: &str,
500 _config: &HyperliquidInstrumentInfo,
501) -> Result<Price, ConversionError> {
502 let _decimal = Decimal::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
503 value: price_str.to_string(),
504 })?;
505
506 Price::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
507 value: price_str.to_string(),
508 })
509}
510
511fn parse_size(
513 size_str: &str,
514 _config: &HyperliquidInstrumentInfo,
515) -> Result<Quantity, ConversionError> {
516 let _decimal = Decimal::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
517 value: size_str.to_string(),
518 })?;
519
520 Quantity::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
521 value: size_str.to_string(),
522 })
523}
524
525#[derive(Debug, Clone, PartialEq, Eq)]
527pub enum ConversionError {
528 InvalidPrice { value: String },
530 InvalidSize { value: String },
532 OrderBookDeltasError(String),
534}
535
536impl From<anyhow::Error> for ConversionError {
537 fn from(err: anyhow::Error) -> Self {
538 Self::OrderBookDeltasError(err.to_string())
539 }
540}
541
542impl Display for ConversionError {
543 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
544 match self {
545 Self::InvalidPrice { value } => write!(f, "Invalid price: {value}"),
546 Self::InvalidSize { value } => write!(f, "Invalid size: {value}"),
547 Self::OrderBookDeltasError(msg) => {
548 write!(f, "OrderBookDeltas error: {msg}")
549 }
550 }
551 }
552}
553
554impl std::error::Error for ConversionError {}
555
556#[derive(Clone, Debug)]
569pub struct HyperliquidPositionData {
570 pub asset: String,
571 pub position: Decimal, pub entry_px: Option<Decimal>,
573 pub unrealized_pnl: Decimal,
574 pub cumulative_funding: Option<Decimal>,
575 pub position_value: Decimal,
576}
577
578impl HyperliquidPositionData {
579 pub fn is_flat(&self) -> bool {
581 self.position.is_zero()
582 }
583
584 pub fn is_long(&self) -> bool {
586 self.position > Decimal::ZERO
587 }
588
589 pub fn is_short(&self) -> bool {
591 self.position < Decimal::ZERO
592 }
593}
594
595#[derive(Clone, Debug)]
603pub struct HyperliquidBalance {
604 pub asset: String,
605 pub total: Decimal,
606 pub available: Decimal,
607 pub sequence: u64,
608 pub ts_event: UnixNanos,
609}
610
611impl HyperliquidBalance {
612 pub fn new(
613 asset: String,
614 total: Decimal,
615 available: Decimal,
616 sequence: u64,
617 ts_event: UnixNanos,
618 ) -> Self {
619 Self {
620 asset,
621 total,
622 available,
623 sequence,
624 ts_event,
625 }
626 }
627
628 pub fn locked(&self) -> Decimal {
630 (self.total - self.available).max(Decimal::ZERO)
631 }
632}
633
634#[derive(Default, Debug)]
642pub struct HyperliquidAccountState {
643 pub balances: HashMap<String, HyperliquidBalance>,
644 pub last_sequence: u64,
645}
646
647impl HyperliquidAccountState {
648 pub fn new() -> Self {
649 Default::default()
650 }
651
652 pub fn get_balance(&self, asset: &str) -> HyperliquidBalance {
654 self.balances.get(asset).cloned().unwrap_or_else(|| {
655 HyperliquidBalance::new(
656 asset.to_string(),
657 Decimal::ZERO,
658 Decimal::ZERO,
659 0,
660 UnixNanos::default(),
661 )
662 })
663 }
664
665 pub fn account_value(&self) -> Decimal {
669 self.balances.values().map(|balance| balance.total).sum()
670 }
671
672 pub fn to_account_state(
681 &self,
682 account_id: AccountId,
683 ts_event: UnixNanos,
684 ts_init: UnixNanos,
685 ) -> anyhow::Result<AccountState> {
686 let balances: Vec<AccountBalance> = self
688 .balances
689 .values()
690 .map(|balance| {
691 let currency = get_currency(&balance.asset);
693
694 let total = Money::new(balance.total.to_f64().unwrap_or(0.0), currency);
696 let free = Money::new(balance.available.to_f64().unwrap_or(0.0), currency);
697 let locked = total - free; AccountBalance::new(total, locked, free)
700 })
701 .collect();
702
703 let margins = Vec::new();
706
707 let account_type = AccountType::Margin;
709
710 let is_reported = true;
712
713 let event_id = UUID4::new();
715
716 Ok(AccountState::new(
717 account_id,
718 account_type,
719 balances,
720 margins,
721 is_reported,
722 event_id,
723 ts_event,
724 ts_init,
725 None, ))
727 }
728}
729
730#[derive(Debug, Clone)]
740pub enum HyperliquidAccountEvent {
741 BalanceSnapshot {
743 balances: Vec<HyperliquidBalance>,
744 sequence: u64,
745 },
746 BalanceDelta { balance: HyperliquidBalance },
748}
749
750impl HyperliquidAccountState {
751 pub fn apply(&mut self, event: HyperliquidAccountEvent) {
753 match event {
754 HyperliquidAccountEvent::BalanceSnapshot { balances, sequence } => {
755 self.balances.clear();
756
757 for balance in balances {
758 self.balances.insert(balance.asset.clone(), balance);
759 }
760
761 self.last_sequence = sequence;
762 }
763 HyperliquidAccountEvent::BalanceDelta { balance } => {
764 let sequence = balance.sequence;
765 let entry = self
766 .balances
767 .entry(balance.asset.clone())
768 .or_insert_with(|| balance.clone());
769
770 if sequence > entry.sequence {
772 *entry = balance;
773 self.last_sequence = self.last_sequence.max(sequence);
774 }
775 }
776 }
777 }
778}
779
780pub fn parse_position_status_report(
790 position_data: &HyperliquidPositionData,
791 account_id: AccountId,
792 instrument_id: InstrumentId,
793 ts_init: UnixNanos,
794) -> anyhow::Result<PositionStatusReport> {
795 let position_side = if position_data.is_flat() {
797 PositionSide::Flat
798 } else if position_data.is_long() {
799 PositionSide::Long
800 } else {
801 PositionSide::Short
802 };
803
804 let quantity = Quantity::new(position_data.position.abs().to_f64().unwrap_or(0.0), 0);
806
807 let ts_last = ts_init;
809
810 let avg_px_open = position_data.entry_px;
812
813 Ok(PositionStatusReport::new(
814 account_id,
815 instrument_id,
816 position_side.as_specified(),
817 quantity,
818 ts_last,
819 ts_init,
820 None, None, avg_px_open,
823 ))
824}
825
826#[cfg(test)]
829#[allow(dead_code)]
830mod tests {
831 use rstest::rstest;
832 use rust_decimal_macros::dec;
833
834 use super::*;
835
836 fn load_test_data<T>(filename: &str) -> T
837 where
838 T: serde::de::DeserializeOwned,
839 {
840 let path = format!("test_data/{filename}");
841 let content = std::fs::read_to_string(path).expect("Failed to read test data");
842 serde_json::from_str(&content).expect("Failed to parse test data")
843 }
844
845 fn test_instrument_id() -> InstrumentId {
846 InstrumentId::from("BTC.HYPER")
847 }
848
849 fn sample_http_book() -> HyperliquidL2Book {
850 load_test_data("http_l2_book_snapshot.json")
851 }
852
853 fn sample_ws_book() -> WsBookData {
854 load_test_data("ws_book_data.json")
855 }
856
857 #[rstest]
858 fn test_http_snapshot_conversion() {
859 let converter = HyperliquidDataConverter::new();
860 let book_data = sample_http_book();
861 let instrument_id = test_instrument_id();
862 let ts_init = UnixNanos::default();
863
864 let deltas = converter
865 .convert_http_snapshot(&book_data, instrument_id, ts_init)
866 .unwrap();
867
868 assert_eq!(deltas.instrument_id, instrument_id);
869 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
873 assert_eq!(clear_delta.instrument_id, instrument_id);
874 assert_eq!(clear_delta.action, BookAction::Clear);
875 assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
876 assert_eq!(clear_delta.order.price.raw, 0);
877 assert_eq!(clear_delta.order.price.precision, 0);
878 assert_eq!(clear_delta.order.size.raw, 0);
879 assert_eq!(clear_delta.order.size.precision, 0);
880 assert_eq!(clear_delta.order.order_id, 0);
881 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
882 assert_eq!(clear_delta.sequence, 0);
883 assert_eq!(
884 clear_delta.ts_event,
885 UnixNanos::from(book_data.time * 1_000_000)
886 );
887 assert_eq!(clear_delta.ts_init, ts_init);
888
889 let first_bid_delta = &deltas.deltas[1];
891 assert_eq!(first_bid_delta.instrument_id, instrument_id);
892 assert_eq!(first_bid_delta.action, BookAction::Add);
893 assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
894 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
895 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
896 assert_eq!(first_bid_delta.order.order_id, 1);
897 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
898 assert_eq!(first_bid_delta.sequence, 1);
899 assert_eq!(
900 first_bid_delta.ts_event,
901 UnixNanos::from(book_data.time * 1_000_000)
902 );
903 assert_eq!(first_bid_delta.ts_init, ts_init);
904
905 for delta in &deltas.deltas[1..] {
907 assert_eq!(delta.action, BookAction::Add);
908 assert!(delta.order.size.is_positive());
909 }
910 }
911
912 #[rstest]
913 fn test_ws_snapshot_conversion() {
914 let converter = HyperliquidDataConverter::new();
915 let book_data = sample_ws_book();
916 let instrument_id = test_instrument_id();
917 let ts_init = UnixNanos::default();
918
919 let deltas = converter
920 .convert_ws_snapshot(&book_data, instrument_id, ts_init)
921 .unwrap();
922
923 assert_eq!(deltas.instrument_id, instrument_id);
924 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
928 assert_eq!(clear_delta.instrument_id, instrument_id);
929 assert_eq!(clear_delta.action, BookAction::Clear);
930 assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
931 assert_eq!(clear_delta.order.price.raw, 0);
932 assert_eq!(clear_delta.order.price.precision, 0);
933 assert_eq!(clear_delta.order.size.raw, 0);
934 assert_eq!(clear_delta.order.size.precision, 0);
935 assert_eq!(clear_delta.order.order_id, 0);
936 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
937 assert_eq!(clear_delta.sequence, 0);
938 assert_eq!(
939 clear_delta.ts_event,
940 UnixNanos::from(book_data.time * 1_000_000)
941 );
942 assert_eq!(clear_delta.ts_init, ts_init);
943
944 let first_bid_delta = &deltas.deltas[1];
946 assert_eq!(first_bid_delta.instrument_id, instrument_id);
947 assert_eq!(first_bid_delta.action, BookAction::Add);
948 assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
949 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
950 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
951 assert_eq!(first_bid_delta.order.order_id, 1);
952 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
953 assert_eq!(first_bid_delta.sequence, 1);
954 assert_eq!(
955 first_bid_delta.ts_event,
956 UnixNanos::from(book_data.time * 1_000_000)
957 );
958 assert_eq!(first_bid_delta.ts_init, ts_init);
959 }
960
961 #[rstest]
962 fn test_delta_update_conversion() {
963 let converter = HyperliquidDataConverter::new();
964 let instrument_id = test_instrument_id();
965 let ts_event = UnixNanos::default();
966 let ts_init = UnixNanos::default();
967
968 let bid_updates = vec![("98450.00".to_string(), "1.5".to_string())];
969 let ask_updates = vec![("98451.00".to_string(), "2.0".to_string())];
970 let bid_removals = vec!["98449.00".to_string()];
971 let ask_removals = vec!["98452.00".to_string()];
972
973 let deltas = converter
974 .convert_delta_update(
975 instrument_id,
976 123,
977 ts_event,
978 ts_init,
979 &bid_updates,
980 &ask_updates,
981 &bid_removals,
982 &ask_removals,
983 )
984 .unwrap();
985
986 assert_eq!(deltas.instrument_id, instrument_id);
987 assert_eq!(deltas.deltas.len(), 4); assert_eq!(deltas.sequence, 123);
989
990 let first_delta = &deltas.deltas[0];
992 assert_eq!(first_delta.instrument_id, instrument_id);
993 assert_eq!(first_delta.action, BookAction::Delete);
994 assert_eq!(first_delta.order.side, OrderSide::Buy);
995 assert_eq!(first_delta.order.price, Price::from("98449.00"));
996 assert_eq!(first_delta.order.size, Quantity::from("0"));
997 assert_eq!(first_delta.order.order_id, 123000);
998 assert_eq!(first_delta.flags, 0);
999 assert_eq!(first_delta.sequence, 123);
1000 assert_eq!(first_delta.ts_event, ts_event);
1001 assert_eq!(first_delta.ts_init, ts_init);
1002 }
1003
1004 #[rstest]
1005 fn test_price_size_parsing() {
1006 let instrument_id = test_instrument_id();
1007 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1008
1009 let price = parse_price("98450.50", &config).unwrap();
1010 assert_eq!(price.to_string(), "98450.50");
1011
1012 let size = parse_size("2.5", &config).unwrap();
1013 assert_eq!(size.to_string(), "2.5");
1014 }
1015
1016 #[rstest]
1017 fn test_hyperliquid_instrument_mini_info() {
1018 let instrument_id = test_instrument_id();
1019
1020 let config = HyperliquidInstrumentInfo::new(instrument_id, 4, 6);
1022 assert_eq!(config.instrument_id, instrument_id);
1023 assert_eq!(config.price_decimals, 4);
1024 assert_eq!(config.size_decimals, 6);
1025
1026 let default_config = HyperliquidInstrumentInfo::default_crypto(instrument_id);
1028 assert_eq!(default_config.instrument_id, instrument_id);
1029 assert_eq!(default_config.price_decimals, 2);
1030 assert_eq!(default_config.size_decimals, 5);
1031 }
1032
1033 #[rstest]
1034 fn test_invalid_price_parsing() {
1035 let instrument_id = test_instrument_id();
1036 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1037
1038 let result = parse_price("invalid", &config);
1040 assert!(result.is_err());
1041
1042 match result.unwrap_err() {
1043 ConversionError::InvalidPrice { value } => {
1044 assert_eq!(value, "invalid");
1045 assert!(value.contains("invalid"));
1047 }
1048 _ => panic!("Expected InvalidPrice error"),
1049 }
1050
1051 let size_result = parse_size("not_a_number", &config);
1053 assert!(size_result.is_err());
1054
1055 match size_result.unwrap_err() {
1056 ConversionError::InvalidSize { value } => {
1057 assert_eq!(value, "not_a_number");
1058 assert!(value.contains("not_a_number"));
1060 }
1061 _ => panic!("Expected InvalidSize error"),
1062 }
1063 }
1064
1065 #[rstest]
1066 fn test_configuration() {
1067 let mut converter = HyperliquidDataConverter::new();
1068 let eth_id = InstrumentId::from("ETH.HYPER");
1069 let config = HyperliquidInstrumentInfo::new(eth_id, 4, 8);
1070
1071 let asset = Ustr::from("ETH");
1072
1073 converter.configure_instrument(asset.as_str(), config.clone());
1074
1075 let retrieved_config = converter.get_config(&asset);
1077 assert_eq!(retrieved_config.instrument_id, eth_id);
1078 assert_eq!(retrieved_config.price_decimals, 4);
1079 assert_eq!(retrieved_config.size_decimals, 8);
1080
1081 let default_config = converter.get_config(&Ustr::from("UNKNOWN"));
1083 assert_eq!(
1084 default_config.instrument_id,
1085 InstrumentId::from("UNKNOWN.HYPER")
1086 );
1087 assert_eq!(default_config.price_decimals, 2);
1088 assert_eq!(default_config.size_decimals, 5);
1089
1090 assert_eq!(config.instrument_id, eth_id);
1092 assert_eq!(config.price_decimals, 4);
1093 assert_eq!(config.size_decimals, 8);
1094 }
1095
1096 #[rstest]
1097 fn test_instrument_info_creation() {
1098 let instrument_id = InstrumentId::from("BTC.HYPER");
1099 let info = HyperliquidInstrumentInfo::with_metadata(
1100 instrument_id,
1101 2,
1102 5,
1103 Decimal::from_f64_retain(0.01).unwrap(),
1104 Decimal::from_f64_retain(0.00001).unwrap(),
1105 Decimal::from_f64_retain(10.0).unwrap(),
1106 );
1107
1108 assert_eq!(info.instrument_id, instrument_id);
1109 assert_eq!(info.price_decimals, 2);
1110 assert_eq!(info.size_decimals, 5);
1111 assert_eq!(
1112 info.tick_size,
1113 Some(Decimal::from_f64_retain(0.01).unwrap())
1114 );
1115 assert_eq!(
1116 info.step_size,
1117 Some(Decimal::from_f64_retain(0.00001).unwrap())
1118 );
1119 assert_eq!(
1120 info.min_notional,
1121 Some(Decimal::from_f64_retain(10.0).unwrap())
1122 );
1123 }
1124
1125 #[rstest]
1126 fn test_instrument_info_with_precision() {
1127 let instrument_id = test_instrument_id();
1128 let info = HyperliquidInstrumentInfo::with_precision(instrument_id, 3, 4);
1129 assert_eq!(info.instrument_id, instrument_id);
1130 assert_eq!(info.price_decimals, 3);
1131 assert_eq!(info.size_decimals, 4);
1132 assert_eq!(info.tick_size, Some(dec!(0.001))); assert_eq!(info.step_size, Some(dec!(0.0001))); }
1135
1136 #[tokio::test]
1137 async fn test_instrument_cache_basic_operations() {
1138 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1139 InstrumentId::from("BTC.HYPER"),
1140 2,
1141 5,
1142 Decimal::from_f64_retain(0.01).unwrap(),
1143 Decimal::from_f64_retain(0.00001).unwrap(),
1144 Decimal::from_f64_retain(10.0).unwrap(),
1145 );
1146
1147 let eth_info = HyperliquidInstrumentInfo::with_metadata(
1148 InstrumentId::from("ETH.HYPER"),
1149 2,
1150 4,
1151 Decimal::from_f64_retain(0.01).unwrap(),
1152 Decimal::from_f64_retain(0.0001).unwrap(),
1153 Decimal::from_f64_retain(10.0).unwrap(),
1154 );
1155
1156 let mut cache = HyperliquidInstrumentCache::new();
1157
1158 cache.insert("BTC", btc_info.clone());
1160 cache.insert("ETH", eth_info.clone());
1161
1162 let retrieved_btc = cache.get("BTC").unwrap();
1164 assert_eq!(retrieved_btc.instrument_id, btc_info.instrument_id);
1165 assert_eq!(retrieved_btc.size_decimals, 5);
1166
1167 let retrieved_eth = cache.get("ETH").unwrap();
1169 assert_eq!(retrieved_eth.instrument_id, eth_info.instrument_id);
1170 assert_eq!(retrieved_eth.size_decimals, 4);
1171
1172 assert_eq!(cache.len(), 2);
1174 assert!(!cache.is_empty());
1175
1176 assert!(cache.contains("BTC"));
1178 assert!(cache.contains("ETH"));
1179 assert!(!cache.contains("UNKNOWN"));
1180
1181 let all_instruments = cache.get_all();
1183 assert_eq!(all_instruments.len(), 2);
1184 }
1185
1186 #[rstest]
1187 fn test_instrument_cache_empty() {
1188 let cache = HyperliquidInstrumentCache::new();
1189 let result = cache.get("UNKNOWN");
1190 assert!(result.is_none());
1191 assert!(cache.is_empty());
1192 assert_eq!(cache.len(), 0);
1193 }
1194
1195 #[rstest]
1196 fn test_normalize_order_for_symbol() {
1197 use rust_decimal_macros::dec;
1198
1199 let mut converter = HyperliquidDataConverter::new();
1200
1201 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1203 InstrumentId::from("BTC.HYPER"),
1204 2,
1205 5,
1206 dec!(0.01), dec!(0.00001), dec!(10.0), );
1210 converter.configure_instrument("BTC", btc_info);
1211
1212 let result = converter.normalize_order_for_symbol(
1214 "BTC",
1215 dec!(50123.456789), dec!(0.123456789), );
1218
1219 assert!(result.is_ok());
1220 let (price, qty) = result.unwrap();
1221 assert_eq!(price, dec!(50123.45)); assert_eq!(qty, dec!(0.12345)); let result_eth = converter.normalize_order_for_symbol("ETH", dec!(3000.123), dec!(1.23456));
1226 assert!(result_eth.is_ok());
1227
1228 let result_fail = converter.normalize_order_for_symbol(
1230 "BTC",
1231 dec!(1.0), dec!(0.001), );
1234 assert!(result_fail.is_err());
1235 assert!(result_fail.unwrap_err().contains("Notional value"));
1236 }
1237
1238 #[rstest]
1239 fn test_hyperliquid_balance_creation_and_properties() {
1240 use rust_decimal_macros::dec;
1241
1242 let asset = "USD".to_string();
1243 let total = dec!(1000.0);
1244 let available = dec!(750.0);
1245 let sequence = 42;
1246 let ts_event = UnixNanos::default();
1247
1248 let balance = HyperliquidBalance::new(asset.clone(), total, available, sequence, ts_event);
1249
1250 assert_eq!(balance.asset, asset);
1251 assert_eq!(balance.total, total);
1252 assert_eq!(balance.available, available);
1253 assert_eq!(balance.sequence, sequence);
1254 assert_eq!(balance.ts_event, ts_event);
1255 assert_eq!(balance.locked(), dec!(250.0)); let full_balance = HyperliquidBalance::new(
1259 "ETH".to_string(),
1260 dec!(100.0),
1261 dec!(100.0),
1262 1,
1263 UnixNanos::default(),
1264 );
1265 assert_eq!(full_balance.locked(), dec!(0.0));
1266
1267 let weird_balance = HyperliquidBalance::new(
1269 "WEIRD".to_string(),
1270 dec!(50.0),
1271 dec!(60.0),
1272 1,
1273 UnixNanos::default(),
1274 );
1275 assert_eq!(weird_balance.locked(), dec!(0.0));
1276 }
1277
1278 #[rstest]
1279 fn test_hyperliquid_account_state_creation() {
1280 let state = HyperliquidAccountState::new();
1281 assert!(state.balances.is_empty());
1282 assert_eq!(state.last_sequence, 0);
1283
1284 let default_state = HyperliquidAccountState::default();
1285 assert!(default_state.balances.is_empty());
1286 assert_eq!(default_state.last_sequence, 0);
1287 }
1288
1289 #[rstest]
1290 fn test_hyperliquid_account_state_getters() {
1291 use rust_decimal_macros::dec;
1292
1293 let mut state = HyperliquidAccountState::new();
1294
1295 let balance = state.get_balance("USD");
1297 assert_eq!(balance.asset, "USD");
1298 assert_eq!(balance.total, dec!(0.0));
1299 assert_eq!(balance.available, dec!(0.0));
1300
1301 let real_balance = HyperliquidBalance::new(
1303 "USD".to_string(),
1304 dec!(1000.0),
1305 dec!(750.0),
1306 1,
1307 UnixNanos::default(),
1308 );
1309 state.balances.insert("USD".to_string(), real_balance);
1310
1311 let retrieved_balance = state.get_balance("USD");
1313 assert_eq!(retrieved_balance.total, dec!(1000.0));
1314 }
1315
1316 #[rstest]
1317 fn test_hyperliquid_account_state_account_value() {
1318 use rust_decimal_macros::dec;
1319
1320 let mut state = HyperliquidAccountState::new();
1321
1322 state.balances.insert(
1324 "USD".to_string(),
1325 HyperliquidBalance::new(
1326 "USD".to_string(),
1327 dec!(10000.0),
1328 dec!(5000.0),
1329 1,
1330 UnixNanos::default(),
1331 ),
1332 );
1333
1334 let total_value = state.account_value();
1335 assert_eq!(total_value, dec!(10000.0));
1336
1337 state.balances.clear();
1339 let no_balance_value = state.account_value();
1340 assert_eq!(no_balance_value, dec!(0.0));
1341 }
1342
1343 #[rstest]
1344 fn test_hyperliquid_account_event_balance_snapshot() {
1345 use rust_decimal_macros::dec;
1346
1347 let mut state = HyperliquidAccountState::new();
1348
1349 let balance = HyperliquidBalance::new(
1350 "USD".to_string(),
1351 dec!(1000.0),
1352 dec!(750.0),
1353 10,
1354 UnixNanos::default(),
1355 );
1356
1357 let snapshot_event = HyperliquidAccountEvent::BalanceSnapshot {
1358 balances: vec![balance],
1359 sequence: 10,
1360 };
1361
1362 state.apply(snapshot_event);
1363
1364 assert_eq!(state.balances.len(), 1);
1365 assert_eq!(state.last_sequence, 10);
1366 assert_eq!(state.get_balance("USD").total, dec!(1000.0));
1367 }
1368
1369 #[rstest]
1370 fn test_hyperliquid_account_event_balance_delta() {
1371 use rust_decimal_macros::dec;
1372
1373 let mut state = HyperliquidAccountState::new();
1374
1375 let initial_balance = HyperliquidBalance::new(
1377 "USD".to_string(),
1378 dec!(1000.0),
1379 dec!(750.0),
1380 5,
1381 UnixNanos::default(),
1382 );
1383 state.balances.insert("USD".to_string(), initial_balance);
1384 state.last_sequence = 5;
1385
1386 let updated_balance = HyperliquidBalance::new(
1388 "USD".to_string(),
1389 dec!(1200.0),
1390 dec!(900.0),
1391 10,
1392 UnixNanos::default(),
1393 );
1394
1395 let delta_event = HyperliquidAccountEvent::BalanceDelta {
1396 balance: updated_balance,
1397 };
1398
1399 state.apply(delta_event);
1400
1401 let balance = state.get_balance("USD");
1402 assert_eq!(balance.total, dec!(1200.0));
1403 assert_eq!(balance.available, dec!(900.0));
1404 assert_eq!(balance.sequence, 10);
1405 assert_eq!(state.last_sequence, 10);
1406
1407 let old_balance = HyperliquidBalance::new(
1409 "USD".to_string(),
1410 dec!(800.0),
1411 dec!(600.0),
1412 8,
1413 UnixNanos::default(),
1414 );
1415
1416 let old_delta_event = HyperliquidAccountEvent::BalanceDelta {
1417 balance: old_balance,
1418 };
1419
1420 state.apply(old_delta_event);
1421
1422 let balance = state.get_balance("USD");
1424 assert_eq!(balance.total, dec!(1200.0)); assert_eq!(balance.sequence, 10); assert_eq!(state.last_sequence, 10); }
1428}