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.configs.get(&data.coin);
251 let ts_event = UnixNanos::from(data.time * 1_000_000);
252 let mut deltas = Vec::with_capacity(1 + data.levels[0].len() + data.levels[1].len());
253
254 deltas.push(OrderBookDelta::clear(
256 instrument_id,
257 0, ts_event,
259 ts_init,
260 ));
261
262 let mut order_id = 1u64; for level in &data.levels[0] {
266 let (price, size) = parse_level(level, config)?;
267 if size.is_positive() {
268 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
269 deltas.push(OrderBookDelta::new(
270 instrument_id,
271 BookAction::Add,
272 order,
273 RecordFlag::F_LAST as u8, order_id,
275 ts_event,
276 ts_init,
277 ));
278 order_id += 1;
279 }
280 }
281
282 for level in &data.levels[1] {
284 let (price, size) = parse_level(level, config)?;
285 if size.is_positive() {
286 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
287 deltas.push(OrderBookDelta::new(
288 instrument_id,
289 BookAction::Add,
290 order,
291 RecordFlag::F_LAST as u8, order_id,
293 ts_event,
294 ts_init,
295 ));
296 order_id += 1;
297 }
298 }
299
300 Ok(OrderBookDeltas::new(instrument_id, deltas))
301 }
302
303 pub fn convert_ws_snapshot(
305 &self,
306 data: &WsBookData,
307 instrument_id: InstrumentId,
308 ts_init: UnixNanos,
309 ) -> Result<OrderBookDeltas, ConversionError> {
310 let config = self.configs.get(&data.coin);
311 let ts_event = UnixNanos::from(data.time * 1_000_000);
312 let mut deltas = Vec::with_capacity(1 + data.levels[0].len() + data.levels[1].len());
313
314 deltas.push(OrderBookDelta::clear(
316 instrument_id,
317 0, ts_event,
319 ts_init,
320 ));
321
322 let mut order_id = 1u64; for level in &data.levels[0] {
326 let (price, size) = parse_ws_level(level, config)?;
327 if size.is_positive() {
328 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
329 deltas.push(OrderBookDelta::new(
330 instrument_id,
331 BookAction::Add,
332 order,
333 RecordFlag::F_LAST as u8,
334 order_id,
335 ts_event,
336 ts_init,
337 ));
338 order_id += 1;
339 }
340 }
341
342 for level in &data.levels[1] {
344 let (price, size) = parse_ws_level(level, config)?;
345 if size.is_positive() {
346 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
347 deltas.push(OrderBookDelta::new(
348 instrument_id,
349 BookAction::Add,
350 order,
351 RecordFlag::F_LAST as u8,
352 order_id,
353 ts_event,
354 ts_init,
355 ));
356 order_id += 1;
357 }
358 }
359
360 Ok(OrderBookDeltas::new(instrument_id, deltas))
361 }
362
363 #[expect(clippy::too_many_arguments)]
366 pub fn convert_delta_update(
367 &self,
368 instrument_id: InstrumentId,
369 sequence: u64,
370 ts_event: UnixNanos,
371 ts_init: UnixNanos,
372 bid_updates: &[(String, String)], ask_updates: &[(String, String)], bid_removals: &[String], ask_removals: &[String], ) -> Result<OrderBookDeltas, ConversionError> {
377 let symbol = instrument_id.symbol.inner();
378 let config = self.configs.get(&symbol);
379 let mut deltas = Vec::new();
380 let mut order_id = sequence * 1000; for price_str in bid_removals {
384 let price = parse_price(price_str, config)?;
385 let size = size_from_decimal(Decimal::ZERO, config.map(|value| value.size_decimals))?;
386 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
387 deltas.push(OrderBookDelta::new(
388 instrument_id,
389 BookAction::Delete,
390 order,
391 0, sequence,
393 ts_event,
394 ts_init,
395 ));
396 order_id += 1;
397 }
398
399 for price_str in ask_removals {
401 let price = parse_price(price_str, config)?;
402 let size = size_from_decimal(Decimal::ZERO, config.map(|value| value.size_decimals))?;
403 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
404 deltas.push(OrderBookDelta::new(
405 instrument_id,
406 BookAction::Delete,
407 order,
408 0, sequence,
410 ts_event,
411 ts_init,
412 ));
413 order_id += 1;
414 }
415
416 for (price_str, size_str) in bid_updates {
418 let price = parse_price(price_str, config)?;
419 let size = parse_size(size_str, config)?;
420
421 if size.is_positive() {
422 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
423 deltas.push(OrderBookDelta::new(
424 instrument_id,
425 BookAction::Update, order,
427 0, sequence,
429 ts_event,
430 ts_init,
431 ));
432 } else {
433 let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
435 deltas.push(OrderBookDelta::new(
436 instrument_id,
437 BookAction::Delete,
438 order,
439 0, sequence,
441 ts_event,
442 ts_init,
443 ));
444 }
445 order_id += 1;
446 }
447
448 for (price_str, size_str) in ask_updates {
450 let price = parse_price(price_str, config)?;
451 let size = parse_size(size_str, config)?;
452
453 if size.is_positive() {
454 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
455 deltas.push(OrderBookDelta::new(
456 instrument_id,
457 BookAction::Update, order,
459 0, sequence,
461 ts_event,
462 ts_init,
463 ));
464 } else {
465 let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
467 deltas.push(OrderBookDelta::new(
468 instrument_id,
469 BookAction::Delete,
470 order,
471 0, sequence,
473 ts_event,
474 ts_init,
475 ));
476 }
477 order_id += 1;
478 }
479
480 Ok(OrderBookDeltas::new(instrument_id, deltas))
481 }
482}
483
484fn parse_level(
486 level: &HyperliquidLevel,
487 config: Option<&HyperliquidInstrumentInfo>,
488) -> Result<(Price, Quantity), ConversionError> {
489 let price = price_from_decimal(level.px, config.map(|value| value.price_decimals))?;
490 let size = size_from_decimal(level.sz, config.map(|value| value.size_decimals))?;
491 Ok((price, size))
492}
493
494fn parse_ws_level(
496 level: &WsLevelData,
497 config: Option<&HyperliquidInstrumentInfo>,
498) -> Result<(Price, Quantity), ConversionError> {
499 let price = price_from_decimal(level.px, config.map(|value| value.price_decimals))?;
500 let size = size_from_decimal(level.sz, config.map(|value| value.size_decimals))?;
501 Ok((price, size))
502}
503
504fn parse_price(
506 price_str: &str,
507 config: Option<&HyperliquidInstrumentInfo>,
508) -> Result<Price, ConversionError> {
509 let decimal = Decimal::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
510 value: price_str.to_string(),
511 })?;
512
513 price_from_decimal(decimal, config.map(|value| value.price_decimals)).map_err(|_| {
514 ConversionError::InvalidPrice {
515 value: price_str.to_string(),
516 }
517 })
518}
519
520fn parse_size(
522 size_str: &str,
523 config: Option<&HyperliquidInstrumentInfo>,
524) -> Result<Quantity, ConversionError> {
525 let decimal = Decimal::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
526 value: size_str.to_string(),
527 })?;
528
529 size_from_decimal(decimal, config.map(|value| value.size_decimals)).map_err(|_| {
530 ConversionError::InvalidSize {
531 value: size_str.to_string(),
532 }
533 })
534}
535
536fn price_from_decimal(value: Decimal, precision: Option<u8>) -> Result<Price, ConversionError> {
537 match precision {
538 Some(precision) => Price::from_decimal_dp(value, precision),
539 None => Price::from_decimal(value),
540 }
541 .map_err(|_| ConversionError::InvalidPrice {
542 value: value.to_string(),
543 })
544}
545
546fn size_from_decimal(value: Decimal, precision: Option<u8>) -> Result<Quantity, ConversionError> {
547 match precision {
548 Some(precision) => Quantity::from_decimal_dp(value, precision),
549 None => Quantity::from_decimal(value),
550 }
551 .map_err(|_| ConversionError::InvalidSize {
552 value: value.to_string(),
553 })
554}
555
556#[derive(Debug, Clone, PartialEq, Eq)]
558pub enum ConversionError {
559 InvalidPrice { value: String },
561 InvalidSize { value: String },
563 OrderBookDeltasError(String),
565}
566
567impl From<anyhow::Error> for ConversionError {
568 fn from(err: anyhow::Error) -> Self {
569 Self::OrderBookDeltasError(err.to_string())
570 }
571}
572
573impl Display for ConversionError {
574 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
575 match self {
576 Self::InvalidPrice { value } => write!(f, "Invalid price: {value}"),
577 Self::InvalidSize { value } => write!(f, "Invalid size: {value}"),
578 Self::OrderBookDeltasError(msg) => {
579 write!(f, "OrderBookDeltas error: {msg}")
580 }
581 }
582 }
583}
584
585impl std::error::Error for ConversionError {}
586
587#[derive(Clone, Debug)]
596pub struct HyperliquidPositionData {
597 pub asset: String,
598 pub position: Decimal, pub entry_px: Option<Decimal>,
600 pub unrealized_pnl: Decimal,
601 pub cumulative_funding: Option<Decimal>,
602 pub position_value: Decimal,
603}
604
605impl HyperliquidPositionData {
606 pub fn is_flat(&self) -> bool {
608 self.position.is_zero()
609 }
610
611 pub fn is_long(&self) -> bool {
613 self.position > Decimal::ZERO
614 }
615
616 pub fn is_short(&self) -> bool {
618 self.position < Decimal::ZERO
619 }
620}
621
622#[derive(Clone, Debug)]
630pub struct HyperliquidBalance {
631 pub asset: String,
632 pub total: Decimal,
633 pub available: Decimal,
634 pub sequence: u64,
635 pub ts_event: UnixNanos,
636}
637
638impl HyperliquidBalance {
639 pub fn new(
640 asset: String,
641 total: Decimal,
642 available: Decimal,
643 sequence: u64,
644 ts_event: UnixNanos,
645 ) -> Self {
646 Self {
647 asset,
648 total,
649 available,
650 sequence,
651 ts_event,
652 }
653 }
654
655 pub fn locked(&self) -> Decimal {
657 (self.total - self.available).max(Decimal::ZERO)
658 }
659}
660
661#[derive(Default, Debug)]
669pub struct HyperliquidAccountState {
670 pub balances: AHashMap<String, HyperliquidBalance>,
671 pub last_sequence: u64,
672}
673
674impl HyperliquidAccountState {
675 pub fn new() -> Self {
676 Self::default()
677 }
678
679 pub fn get_balance(&self, asset: &str) -> HyperliquidBalance {
681 self.balances.get(asset).cloned().unwrap_or_else(|| {
682 HyperliquidBalance::new(
683 asset.to_string(),
684 Decimal::ZERO,
685 Decimal::ZERO,
686 0,
687 UnixNanos::default(),
688 )
689 })
690 }
691
692 pub fn account_value(&self) -> Decimal {
696 self.balances.values().map(|balance| balance.total).sum()
697 }
698
699 pub fn to_account_state(
708 &self,
709 account_id: AccountId,
710 ts_event: UnixNanos,
711 ts_init: UnixNanos,
712 ) -> anyhow::Result<AccountState> {
713 let balances: Vec<AccountBalance> = self
715 .balances
716 .values()
717 .map(|balance| {
718 let currency = get_currency(&balance.asset);
720 AccountBalance::from_total_and_free(balance.total, balance.available, currency)
721 .map_err(anyhow::Error::from)
722 })
723 .collect::<anyhow::Result<Vec<_>>>()?;
724
725 let margins = Vec::new();
727
728 let account_type = AccountType::Margin;
729 let is_reported = true;
730 let event_id = UUID4::new();
731
732 Ok(AccountState::new(
733 account_id,
734 account_type,
735 balances,
736 margins,
737 is_reported,
738 event_id,
739 ts_event,
740 ts_init,
741 None, ))
743 }
744}
745
746#[derive(Debug, Clone)]
756pub enum HyperliquidAccountEvent {
757 BalanceSnapshot {
759 balances: Vec<HyperliquidBalance>,
760 sequence: u64,
761 },
762 BalanceDelta { balance: HyperliquidBalance },
764}
765
766impl HyperliquidAccountState {
767 pub fn apply(&mut self, event: HyperliquidAccountEvent) {
769 match event {
770 HyperliquidAccountEvent::BalanceSnapshot { balances, sequence } => {
771 self.balances.clear();
772
773 for balance in balances {
774 self.balances.insert(balance.asset.clone(), balance);
775 }
776
777 self.last_sequence = sequence;
778 }
779 HyperliquidAccountEvent::BalanceDelta { balance } => {
780 let sequence = balance.sequence;
781 let entry = self
782 .balances
783 .entry(balance.asset.clone())
784 .or_insert_with(|| balance.clone());
785
786 if sequence > entry.sequence {
788 *entry = balance;
789 self.last_sequence = self.last_sequence.max(sequence);
790 }
791 }
792 }
793 }
794}
795
796pub fn parse_position_status_report(
806 position_data: &HyperliquidPositionData,
807 account_id: AccountId,
808 instrument_id: InstrumentId,
809 ts_init: UnixNanos,
810) -> anyhow::Result<PositionStatusReport> {
811 let position_side = if position_data.is_flat() {
813 PositionSide::Flat
814 } else if position_data.is_long() {
815 PositionSide::Long
816 } else {
817 PositionSide::Short
818 };
819
820 let quantity = Quantity::from_decimal(position_data.position.abs())?;
822
823 let ts_last = ts_init;
824 let avg_px_open = position_data.entry_px;
825
826 Ok(PositionStatusReport::new(
827 account_id,
828 instrument_id,
829 position_side,
830 quantity,
831 ts_last,
832 ts_init,
833 None, None, avg_px_open,
836 ))
837}
838
839#[cfg(test)]
840#[allow(dead_code)]
841mod tests {
842 use nautilus_model::enums::OrderSide;
843 use rstest::rstest;
844 use rust_decimal_macros::dec;
845
846 use super::*;
847 use crate::common::testing::load_test_data;
848
849 fn test_instrument_id() -> InstrumentId {
850 InstrumentId::from("BTC.HYPER")
851 }
852
853 fn sample_http_book() -> HyperliquidL2Book {
854 load_test_data("http_l2_book_snapshot.json")
855 }
856
857 fn sample_ws_book() -> WsBookData {
858 load_test_data("ws_book_data.json")
859 }
860
861 #[rstest]
862 fn test_http_snapshot_conversion() {
863 let converter = HyperliquidDataConverter::new();
864 let book_data = sample_http_book();
865 let instrument_id = test_instrument_id();
866 let ts_init = UnixNanos::default();
867
868 let deltas = converter
869 .convert_http_snapshot(&book_data, instrument_id, ts_init)
870 .unwrap();
871
872 assert_eq!(deltas.instrument_id, instrument_id);
873 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
877 assert_eq!(clear_delta.instrument_id, instrument_id);
878 assert_eq!(clear_delta.action, BookAction::Clear);
879 assert_eq!(clear_delta.order.side, None);
880 assert_eq!(clear_delta.order.price.raw, 0);
881 assert_eq!(clear_delta.order.price.precision, 0);
882 assert_eq!(clear_delta.order.size.raw, 0);
883 assert_eq!(clear_delta.order.size.precision, 0);
884 assert_eq!(clear_delta.order.order_id, 0);
885 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
886 assert_eq!(clear_delta.sequence, 0);
887 assert_eq!(
888 clear_delta.ts_event,
889 UnixNanos::from(book_data.time * 1_000_000)
890 );
891 assert_eq!(clear_delta.ts_init, ts_init);
892
893 let first_bid_delta = &deltas.deltas[1];
895 assert_eq!(first_bid_delta.instrument_id, instrument_id);
896 assert_eq!(first_bid_delta.action, BookAction::Add);
897 assert_eq!(first_bid_delta.order.side, OrderSide::Buy.into());
898 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
899 assert_eq!(first_bid_delta.order.price.precision, 2);
900 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
901 assert_eq!(first_bid_delta.order.size.precision, 1);
902 assert_eq!(first_bid_delta.order.order_id, 1);
903 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
904 assert_eq!(first_bid_delta.sequence, 1);
905 assert_eq!(
906 first_bid_delta.ts_event,
907 UnixNanos::from(book_data.time * 1_000_000)
908 );
909 assert_eq!(first_bid_delta.ts_init, ts_init);
910
911 for delta in &deltas.deltas[1..] {
913 assert_eq!(delta.action, BookAction::Add);
914 assert!(delta.order.size.is_positive());
915 }
916 }
917
918 #[rstest]
919 fn test_ws_snapshot_conversion() {
920 let converter = HyperliquidDataConverter::new();
921 let book_data = sample_ws_book();
922 let instrument_id = test_instrument_id();
923 let ts_init = UnixNanos::default();
924
925 let deltas = converter
926 .convert_ws_snapshot(&book_data, instrument_id, ts_init)
927 .unwrap();
928
929 assert_eq!(deltas.instrument_id, instrument_id);
930 assert_eq!(deltas.deltas.len(), 11); let clear_delta = &deltas.deltas[0];
934 assert_eq!(clear_delta.instrument_id, instrument_id);
935 assert_eq!(clear_delta.action, BookAction::Clear);
936 assert_eq!(clear_delta.order.side, None);
937 assert_eq!(clear_delta.order.price.raw, 0);
938 assert_eq!(clear_delta.order.price.precision, 0);
939 assert_eq!(clear_delta.order.size.raw, 0);
940 assert_eq!(clear_delta.order.size.precision, 0);
941 assert_eq!(clear_delta.order.order_id, 0);
942 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
943 assert_eq!(clear_delta.sequence, 0);
944 assert_eq!(
945 clear_delta.ts_event,
946 UnixNanos::from(book_data.time * 1_000_000)
947 );
948 assert_eq!(clear_delta.ts_init, ts_init);
949
950 let first_bid_delta = &deltas.deltas[1];
952 assert_eq!(first_bid_delta.instrument_id, instrument_id);
953 assert_eq!(first_bid_delta.action, BookAction::Add);
954 assert_eq!(first_bid_delta.order.side, OrderSide::Buy.into());
955 assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
956 assert_eq!(first_bid_delta.order.price.precision, 2);
957 assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
958 assert_eq!(first_bid_delta.order.size.precision, 1);
959 assert_eq!(first_bid_delta.order.order_id, 1);
960 assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
961 assert_eq!(first_bid_delta.sequence, 1);
962 assert_eq!(
963 first_bid_delta.ts_event,
964 UnixNanos::from(book_data.time * 1_000_000)
965 );
966 assert_eq!(first_bid_delta.ts_init, ts_init);
967 }
968
969 #[rstest]
970 fn test_delta_update_conversion() {
971 let mut converter = HyperliquidDataConverter::new();
972 let instrument_id = test_instrument_id();
973 converter.configure_instrument("BTC", HyperliquidInstrumentInfo::new(instrument_id, 2, 5));
974 let ts_event = UnixNanos::default();
975 let ts_init = UnixNanos::default();
976
977 let bid_updates = vec![("98450.00".to_string(), "1.5".to_string())];
978 let ask_updates = vec![("98451.00".to_string(), "2.0".to_string())];
979 let bid_removals = vec!["98449.00".to_string()];
980 let ask_removals = vec!["98452.00".to_string()];
981
982 let deltas = converter
983 .convert_delta_update(
984 instrument_id,
985 123,
986 ts_event,
987 ts_init,
988 &bid_updates,
989 &ask_updates,
990 &bid_removals,
991 &ask_removals,
992 )
993 .unwrap();
994
995 assert_eq!(deltas.instrument_id, instrument_id);
996 assert_eq!(deltas.deltas.len(), 4); assert_eq!(deltas.sequence, 123);
998
999 let first_delta = &deltas.deltas[0];
1001 assert_eq!(first_delta.instrument_id, instrument_id);
1002 assert_eq!(first_delta.action, BookAction::Delete);
1003 assert_eq!(first_delta.order.side, OrderSide::Buy.into());
1004 assert_eq!(first_delta.order.price, Price::from("98449.00"));
1005 assert_eq!(first_delta.order.price.precision, 2);
1006 assert_eq!(first_delta.order.size, Quantity::from("0.00000"));
1007 assert_eq!(first_delta.order.size.precision, 5);
1008 assert_eq!(first_delta.order.order_id, 123000);
1009 assert_eq!(first_delta.flags, 0);
1010 assert_eq!(first_delta.sequence, 123);
1011 assert_eq!(first_delta.ts_event, ts_event);
1012 assert_eq!(first_delta.ts_init, ts_init);
1013 }
1014
1015 #[rstest]
1016 fn test_price_size_parsing() {
1017 let instrument_id = test_instrument_id();
1018 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1019
1020 let price = parse_price("25.000", Some(&config)).unwrap();
1021 assert_eq!(price, Price::from("25.00"));
1022 assert_eq!(price.precision, 2);
1023
1024 let size = parse_size("25.000", Some(&config)).unwrap();
1025 assert_eq!(size, Quantity::from("25.00000"));
1026 assert_eq!(size.precision, 5);
1027 }
1028
1029 #[rstest]
1030 fn test_decimal_level_parsing_uses_declared_precision() {
1031 let config = HyperliquidInstrumentInfo::new(test_instrument_id(), 2, 5);
1032 let level = HyperliquidLevel {
1033 px: Decimal::from_str_exact("25.000").unwrap(),
1034 sz: Decimal::from_str_exact("25.000").unwrap(),
1035 };
1036
1037 let (price, size) = parse_level(&level, Some(&config)).unwrap();
1038
1039 assert_eq!(price, Price::from("25.00"));
1040 assert_eq!(price.precision, 2);
1041 assert_eq!(size, Quantity::from("25.00000"));
1042 assert_eq!(size.precision, 5);
1043 }
1044
1045 #[rstest]
1046 fn test_delta_removal_rejects_invalid_size_precision() {
1047 let instrument_id = test_instrument_id();
1048 let mut converter = HyperliquidDataConverter::new();
1049 converter.configure_instrument(
1050 "BTC",
1051 HyperliquidInstrumentInfo::new(instrument_id, 2, u8::MAX),
1052 );
1053
1054 let result = converter.convert_delta_update(
1055 instrument_id,
1056 1,
1057 UnixNanos::default(),
1058 UnixNanos::default(),
1059 &[],
1060 &[],
1061 &["25.000".to_string()],
1062 &[],
1063 );
1064
1065 assert!(matches!(result, Err(ConversionError::InvalidSize { .. })));
1066 }
1067
1068 #[rstest]
1069 fn test_unconfigured_delta_preserves_source_precision() {
1070 let converter = HyperliquidDataConverter::new();
1071 let instrument_id = test_instrument_id();
1072
1073 let deltas = converter
1074 .convert_delta_update(
1075 instrument_id,
1076 1,
1077 UnixNanos::default(),
1078 UnixNanos::default(),
1079 &[("0.0068755".to_string(), "0.0000001".to_string())],
1080 &[],
1081 &[],
1082 &[],
1083 )
1084 .unwrap();
1085 let order = deltas.deltas[0].order;
1086
1087 assert_eq!(order.price, Price::from("0.0068755"));
1088 assert_eq!(order.price.precision, 7);
1089 assert_eq!(order.size, Quantity::from("0.0000001"));
1090 assert_eq!(order.size.precision, 7);
1091 }
1092
1093 #[rstest]
1094 fn test_hyperliquid_instrument_mini_info() {
1095 let instrument_id = test_instrument_id();
1096
1097 let config = HyperliquidInstrumentInfo::new(instrument_id, 4, 6);
1099 assert_eq!(config.instrument_id, instrument_id);
1100 assert_eq!(config.price_decimals, 4);
1101 assert_eq!(config.size_decimals, 6);
1102
1103 let default_config = HyperliquidInstrumentInfo::default_crypto(instrument_id);
1105 assert_eq!(default_config.instrument_id, instrument_id);
1106 assert_eq!(default_config.price_decimals, 2);
1107 assert_eq!(default_config.size_decimals, 5);
1108 }
1109
1110 #[rstest]
1111 fn test_invalid_price_parsing() {
1112 let instrument_id = test_instrument_id();
1113 let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
1114
1115 let result = parse_price("invalid", Some(&config));
1117 assert!(result.is_err());
1118
1119 match result.unwrap_err() {
1120 ConversionError::InvalidPrice { value } => {
1121 assert_eq!(value, "invalid");
1122 assert!(value.contains("invalid"));
1124 }
1125 _ => panic!("Expected InvalidPrice error"),
1126 }
1127
1128 let size_result = parse_size("not_a_number", Some(&config));
1130 assert!(size_result.is_err());
1131
1132 match size_result.unwrap_err() {
1133 ConversionError::InvalidSize { value } => {
1134 assert_eq!(value, "not_a_number");
1135 assert!(value.contains("not_a_number"));
1137 }
1138 _ => panic!("Expected InvalidSize error"),
1139 }
1140 }
1141
1142 #[rstest]
1143 fn test_configuration() {
1144 let mut converter = HyperliquidDataConverter::new();
1145 let eth_id = InstrumentId::from("ETH.HYPER");
1146 let config = HyperliquidInstrumentInfo::new(eth_id, 4, 8);
1147
1148 let asset = Ustr::from("ETH");
1149
1150 converter.configure_instrument(asset.as_str(), config.clone());
1151
1152 let retrieved_config = converter.get_config(&asset);
1154 assert_eq!(retrieved_config.instrument_id, eth_id);
1155 assert_eq!(retrieved_config.price_decimals, 4);
1156 assert_eq!(retrieved_config.size_decimals, 8);
1157
1158 let default_config = converter.get_config(&Ustr::from("UNKNOWN"));
1160 assert_eq!(
1161 default_config.instrument_id,
1162 InstrumentId::from("UNKNOWN.HYPER")
1163 );
1164 assert_eq!(default_config.price_decimals, 2);
1165 assert_eq!(default_config.size_decimals, 5);
1166
1167 assert_eq!(config.instrument_id, eth_id);
1169 assert_eq!(config.price_decimals, 4);
1170 assert_eq!(config.size_decimals, 8);
1171 }
1172
1173 #[rstest]
1174 fn test_instrument_info_creation() {
1175 let instrument_id = InstrumentId::from("BTC.HYPER");
1176 let info = HyperliquidInstrumentInfo::with_metadata(
1177 instrument_id,
1178 2,
1179 5,
1180 dec!(0.01),
1181 dec!(0.00001),
1182 dec!(10),
1183 );
1184
1185 assert_eq!(info.instrument_id, instrument_id);
1186 assert_eq!(info.price_decimals, 2);
1187 assert_eq!(info.size_decimals, 5);
1188 assert_eq!(info.tick_size, Some(dec!(0.01)));
1189 assert_eq!(info.step_size, Some(dec!(0.00001)));
1190 assert_eq!(info.min_notional, Some(dec!(10)));
1191 }
1192
1193 #[rstest]
1194 fn test_instrument_info_with_precision() {
1195 let instrument_id = test_instrument_id();
1196 let info = HyperliquidInstrumentInfo::with_precision(instrument_id, 3, 4);
1197 assert_eq!(info.instrument_id, instrument_id);
1198 assert_eq!(info.price_decimals, 3);
1199 assert_eq!(info.size_decimals, 4);
1200 assert_eq!(info.tick_size, Some(dec!(0.001))); assert_eq!(info.step_size, Some(dec!(0.0001))); }
1203
1204 #[tokio::test]
1205 async fn test_instrument_cache_basic_operations() {
1206 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1207 InstrumentId::from("BTC.HYPER"),
1208 2,
1209 5,
1210 dec!(0.01),
1211 dec!(0.00001),
1212 dec!(10),
1213 );
1214
1215 let eth_info = HyperliquidInstrumentInfo::with_metadata(
1216 InstrumentId::from("ETH.HYPER"),
1217 2,
1218 4,
1219 dec!(0.01),
1220 dec!(0.0001),
1221 dec!(10),
1222 );
1223
1224 let mut cache = HyperliquidInstrumentCache::new();
1225
1226 cache.insert("BTC", btc_info.clone());
1228 cache.insert("ETH", eth_info.clone());
1229
1230 let retrieved_btc = cache.get("BTC").unwrap();
1232 assert_eq!(retrieved_btc.instrument_id, btc_info.instrument_id);
1233 assert_eq!(retrieved_btc.size_decimals, 5);
1234
1235 let retrieved_eth = cache.get("ETH").unwrap();
1237 assert_eq!(retrieved_eth.instrument_id, eth_info.instrument_id);
1238 assert_eq!(retrieved_eth.size_decimals, 4);
1239
1240 assert_eq!(cache.len(), 2);
1242 assert!(!cache.is_empty());
1243
1244 assert!(cache.contains("BTC"));
1246 assert!(cache.contains("ETH"));
1247 assert!(!cache.contains("UNKNOWN"));
1248
1249 let all_instruments = cache.get_all();
1251 assert_eq!(all_instruments.len(), 2);
1252 }
1253
1254 #[rstest]
1255 fn test_instrument_cache_empty() {
1256 let cache = HyperliquidInstrumentCache::new();
1257 let result = cache.get("UNKNOWN");
1258 assert!(result.is_none());
1259 assert!(cache.is_empty());
1260 assert_eq!(cache.len(), 0);
1261 }
1262
1263 #[rstest]
1264 fn test_normalize_order_for_symbol() {
1265 use rust_decimal_macros::dec;
1266
1267 let mut converter = HyperliquidDataConverter::new();
1268
1269 let btc_info = HyperliquidInstrumentInfo::with_metadata(
1271 InstrumentId::from("BTC.HYPER"),
1272 2,
1273 5,
1274 dec!(0.01), dec!(0.00001), dec!(10.0), );
1278 converter.configure_instrument("BTC", btc_info);
1279
1280 let result = converter.normalize_order_for_symbol(
1282 "BTC",
1283 dec!(50123.456789), dec!(0.123456789), );
1286
1287 assert!(result.is_ok());
1288 let (price, qty) = result.unwrap();
1289 assert_eq!(price, dec!(50123.00));
1291 assert_eq!(qty, dec!(0.12345)); let result_eth = converter.normalize_order_for_symbol("ETH", dec!(3000.123), dec!(1.23456));
1295 assert!(result_eth.is_ok());
1296
1297 let result_fail = converter.normalize_order_for_symbol(
1299 "BTC",
1300 dec!(1.0), dec!(0.001), );
1303 assert!(result_fail.is_err());
1304 assert!(result_fail.unwrap_err().contains("Notional value"));
1305 }
1306
1307 #[rstest]
1308 fn test_hyperliquid_balance_creation_and_properties() {
1309 use rust_decimal_macros::dec;
1310
1311 let asset = "USD".to_string();
1312 let total = dec!(1000.0);
1313 let available = dec!(750.0);
1314 let sequence = 42;
1315 let ts_event = UnixNanos::default();
1316
1317 let balance = HyperliquidBalance::new(asset.clone(), total, available, sequence, ts_event);
1318
1319 assert_eq!(balance.asset, asset);
1320 assert_eq!(balance.total, total);
1321 assert_eq!(balance.available, available);
1322 assert_eq!(balance.sequence, sequence);
1323 assert_eq!(balance.ts_event, ts_event);
1324 assert_eq!(balance.locked(), dec!(250.0)); let full_balance = HyperliquidBalance::new(
1328 "ETH".to_string(),
1329 dec!(100.0),
1330 dec!(100.0),
1331 1,
1332 UnixNanos::default(),
1333 );
1334 assert_eq!(full_balance.locked(), dec!(0.0));
1335
1336 let weird_balance = HyperliquidBalance::new(
1338 "WEIRD".to_string(),
1339 dec!(50.0),
1340 dec!(60.0),
1341 1,
1342 UnixNanos::default(),
1343 );
1344 assert_eq!(weird_balance.locked(), dec!(0.0));
1345 }
1346
1347 #[rstest]
1348 fn test_hyperliquid_account_state_creation() {
1349 let state = HyperliquidAccountState::new();
1350 assert!(state.balances.is_empty());
1351 assert_eq!(state.last_sequence, 0);
1352
1353 let default_state = HyperliquidAccountState::default();
1354 assert!(default_state.balances.is_empty());
1355 assert_eq!(default_state.last_sequence, 0);
1356 }
1357
1358 #[rstest]
1359 fn test_hyperliquid_account_state_getters() {
1360 use rust_decimal_macros::dec;
1361
1362 let mut state = HyperliquidAccountState::new();
1363
1364 let balance = state.get_balance("USD");
1366 assert_eq!(balance.asset, "USD");
1367 assert_eq!(balance.total, dec!(0.0));
1368 assert_eq!(balance.available, dec!(0.0));
1369
1370 let real_balance = HyperliquidBalance::new(
1372 "USD".to_string(),
1373 dec!(1000.0),
1374 dec!(750.0),
1375 1,
1376 UnixNanos::default(),
1377 );
1378 state.balances.insert("USD".to_string(), real_balance);
1379
1380 let retrieved_balance = state.get_balance("USD");
1382 assert_eq!(retrieved_balance.total, dec!(1000.0));
1383 }
1384
1385 #[rstest]
1386 fn test_hyperliquid_account_state_account_value() {
1387 use rust_decimal_macros::dec;
1388
1389 let mut state = HyperliquidAccountState::new();
1390
1391 state.balances.insert(
1393 "USD".to_string(),
1394 HyperliquidBalance::new(
1395 "USD".to_string(),
1396 dec!(10000.0),
1397 dec!(5000.0),
1398 1,
1399 UnixNanos::default(),
1400 ),
1401 );
1402
1403 let total_value = state.account_value();
1404 assert_eq!(total_value, dec!(10000.0));
1405
1406 state.balances.clear();
1408 let no_balance_value = state.account_value();
1409 assert_eq!(no_balance_value, dec!(0.0));
1410 }
1411
1412 #[rstest]
1413 fn test_hyperliquid_account_event_balance_snapshot() {
1414 use rust_decimal_macros::dec;
1415
1416 let mut state = HyperliquidAccountState::new();
1417
1418 let balance = HyperliquidBalance::new(
1419 "USD".to_string(),
1420 dec!(1000.0),
1421 dec!(750.0),
1422 10,
1423 UnixNanos::default(),
1424 );
1425
1426 let snapshot_event = HyperliquidAccountEvent::BalanceSnapshot {
1427 balances: vec![balance],
1428 sequence: 10,
1429 };
1430
1431 state.apply(snapshot_event);
1432
1433 assert_eq!(state.balances.len(), 1);
1434 assert_eq!(state.last_sequence, 10);
1435 assert_eq!(state.get_balance("USD").total, dec!(1000.0));
1436 }
1437
1438 #[rstest]
1439 fn test_hyperliquid_account_event_balance_delta() {
1440 use rust_decimal_macros::dec;
1441
1442 let mut state = HyperliquidAccountState::new();
1443
1444 let initial_balance = HyperliquidBalance::new(
1446 "USD".to_string(),
1447 dec!(1000.0),
1448 dec!(750.0),
1449 5,
1450 UnixNanos::default(),
1451 );
1452 state.balances.insert("USD".to_string(), initial_balance);
1453 state.last_sequence = 5;
1454
1455 let updated_balance = HyperliquidBalance::new(
1457 "USD".to_string(),
1458 dec!(1200.0),
1459 dec!(900.0),
1460 10,
1461 UnixNanos::default(),
1462 );
1463
1464 let delta_event = HyperliquidAccountEvent::BalanceDelta {
1465 balance: updated_balance,
1466 };
1467
1468 state.apply(delta_event);
1469
1470 let balance = state.get_balance("USD");
1471 assert_eq!(balance.total, dec!(1200.0));
1472 assert_eq!(balance.available, dec!(900.0));
1473 assert_eq!(balance.sequence, 10);
1474 assert_eq!(state.last_sequence, 10);
1475
1476 let old_balance = HyperliquidBalance::new(
1478 "USD".to_string(),
1479 dec!(800.0),
1480 dec!(600.0),
1481 8,
1482 UnixNanos::default(),
1483 );
1484
1485 let old_delta_event = HyperliquidAccountEvent::BalanceDelta {
1486 balance: old_balance,
1487 };
1488
1489 state.apply(old_delta_event);
1490
1491 let balance = state.get_balance("USD");
1493 assert_eq!(balance.total, dec!(1200.0)); assert_eq!(balance.sequence, 10); assert_eq!(state.last_sequence, 10); }
1497
1498 #[rstest]
1499 fn test_hyperliquid_account_state_to_account_state_uses_from_total_and_free() {
1500 use nautilus_model::identifiers::AccountId;
1501
1502 let mut state = HyperliquidAccountState::new();
1503 state.balances.insert(
1504 "USDC".to_string(),
1505 HyperliquidBalance::new(
1506 "USDC".to_string(),
1507 dec!(10_000),
1508 dec!(7_500),
1509 1,
1510 UnixNanos::default(),
1511 ),
1512 );
1513 state.balances.insert(
1514 "BTC".to_string(),
1515 HyperliquidBalance::new(
1516 "BTC".to_string(),
1517 dec!(1.25),
1518 dec!(1.0),
1519 2,
1520 UnixNanos::default(),
1521 ),
1522 );
1523
1524 let account_id = AccountId::new("HYPERLIQUID-001");
1525 let ts = UnixNanos::default();
1526 let account_state = state.to_account_state(account_id, ts, ts).unwrap();
1527
1528 assert_eq!(account_state.account_id, account_id);
1529 assert_eq!(account_state.balances.len(), 2);
1530
1531 let usdc = account_state
1532 .balances
1533 .iter()
1534 .find(|b| b.currency.code.as_str() == "USDC")
1535 .expect("USDC balance emitted");
1536 assert_eq!(usdc.total.as_decimal(), dec!(10_000));
1537 assert_eq!(usdc.free.as_decimal(), dec!(7_500));
1538 assert_eq!(usdc.locked.as_decimal(), dec!(2_500));
1539
1540 let btc = account_state
1541 .balances
1542 .iter()
1543 .find(|b| b.currency.code.as_str() == "BTC")
1544 .expect("BTC balance emitted");
1545 assert_eq!(btc.total.as_decimal(), dec!(1.25));
1546 assert_eq!(btc.free.as_decimal(), dec!(1.0));
1547 assert_eq!(btc.locked.as_decimal(), dec!(0.25));
1548 }
1549}