use std::{fmt::Display, str::FromStr};
use ahash::AHashMap;
use nautilus_core::{UUID4, UnixNanos};
use nautilus_model::{
data::{delta::OrderBookDelta, deltas::OrderBookDeltas, order::BookOrder},
enums::{AccountType, BookAction, OrderSide, PositionSide, RecordFlag},
events::AccountState,
identifiers::{AccountId, InstrumentId},
reports::PositionStatusReport,
types::{AccountBalance, Money, Price, Quantity},
};
use rust_decimal::Decimal;
use ustr::Ustr;
use crate::{
common::parse::normalize_order,
http::{
models::{HyperliquidL2Book, HyperliquidLevel},
parse::get_currency,
},
websocket::messages::{WsBookData, WsLevelData},
};
#[derive(Debug, Clone)]
pub struct HyperliquidInstrumentInfo {
pub instrument_id: InstrumentId,
pub price_decimals: u8,
pub size_decimals: u8,
pub tick_size: Option<Decimal>,
pub step_size: Option<Decimal>,
pub min_notional: Option<Decimal>,
}
impl HyperliquidInstrumentInfo {
pub fn new(instrument_id: InstrumentId, price_decimals: u8, size_decimals: u8) -> Self {
Self {
instrument_id,
price_decimals,
size_decimals,
tick_size: None,
step_size: None,
min_notional: None,
}
}
pub fn with_metadata(
instrument_id: InstrumentId,
price_decimals: u8,
size_decimals: u8,
tick_size: Decimal,
step_size: Decimal,
min_notional: Decimal,
) -> Self {
Self {
instrument_id,
price_decimals,
size_decimals,
tick_size: Some(tick_size),
step_size: Some(step_size),
min_notional: Some(min_notional),
}
}
pub fn with_precision(
instrument_id: InstrumentId,
price_decimals: u8,
size_decimals: u8,
) -> Self {
let tick_size = Decimal::new(1, price_decimals as u32);
let step_size = Decimal::new(1, size_decimals as u32);
Self {
instrument_id,
price_decimals,
size_decimals,
tick_size: Some(tick_size),
step_size: Some(step_size),
min_notional: None,
}
}
pub fn default_crypto(instrument_id: InstrumentId) -> Self {
Self::with_precision(instrument_id, 2, 5) }
}
#[derive(Debug, Default)]
pub struct HyperliquidInstrumentCache {
instruments_by_symbol: AHashMap<Ustr, HyperliquidInstrumentInfo>,
}
impl HyperliquidInstrumentCache {
pub fn new() -> Self {
Self {
instruments_by_symbol: AHashMap::new(),
}
}
pub fn insert(&mut self, symbol: &str, info: HyperliquidInstrumentInfo) {
self.instruments_by_symbol.insert(Ustr::from(symbol), info);
}
pub fn get(&self, symbol: &str) -> Option<&HyperliquidInstrumentInfo> {
self.instruments_by_symbol.get(&Ustr::from(symbol))
}
pub fn get_all(&self) -> Vec<&HyperliquidInstrumentInfo> {
self.instruments_by_symbol.values().collect()
}
pub fn contains(&self, symbol: &str) -> bool {
self.instruments_by_symbol.contains_key(&Ustr::from(symbol))
}
pub fn len(&self) -> usize {
self.instruments_by_symbol.len()
}
pub fn is_empty(&self) -> bool {
self.instruments_by_symbol.is_empty()
}
pub fn clear(&mut self) {
self.instruments_by_symbol.clear();
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum HyperliquidTradeKey {
Id(String),
Seq(u64),
}
#[derive(Debug)]
pub struct HyperliquidDataConverter {
configs: AHashMap<Ustr, HyperliquidInstrumentInfo>,
}
impl Default for HyperliquidDataConverter {
fn default() -> Self {
Self::new()
}
}
impl HyperliquidDataConverter {
pub fn new() -> Self {
Self {
configs: AHashMap::new(),
}
}
pub fn normalize_order_for_symbol(
&mut self,
symbol: &str,
price: Decimal,
qty: Decimal,
) -> Result<(Decimal, Decimal), String> {
let config = self.get_config(&Ustr::from(symbol));
let tick_size = config.tick_size.unwrap_or_else(|| Decimal::new(1, 2)); let step_size = config.step_size.unwrap_or_else(|| {
match config.size_decimals {
0 => Decimal::ONE,
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), }
});
let min_notional = config.min_notional.unwrap_or_else(|| Decimal::from(10));
normalize_order(
price,
qty,
tick_size,
step_size,
min_notional,
config.price_decimals,
config.size_decimals,
)
}
pub fn configure_instrument(&mut self, symbol: &str, config: HyperliquidInstrumentInfo) {
self.configs.insert(Ustr::from(symbol), config);
}
fn get_config(&self, symbol: &Ustr) -> HyperliquidInstrumentInfo {
self.configs.get(symbol).cloned().unwrap_or_else(|| {
let instrument_id = InstrumentId::from(format!("{symbol}.HYPER"));
HyperliquidInstrumentInfo::default_crypto(instrument_id)
})
}
pub fn convert_http_snapshot(
&self,
data: &HyperliquidL2Book,
instrument_id: InstrumentId,
ts_init: UnixNanos,
) -> Result<OrderBookDeltas, ConversionError> {
let config = self.get_config(&data.coin);
let mut deltas = Vec::new();
deltas.push(OrderBookDelta::clear(
instrument_id,
0, UnixNanos::from(data.time * 1_000_000), ts_init,
));
let mut order_id = 1u64;
for level in &data.levels[0] {
let (price, size) = parse_level(level, &config)?;
if size.is_positive() {
let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
deltas.push(OrderBookDelta::new(
instrument_id,
BookAction::Add,
order,
RecordFlag::F_LAST as u8, order_id,
UnixNanos::from(data.time * 1_000_000),
ts_init,
));
order_id += 1;
}
}
for level in &data.levels[1] {
let (price, size) = parse_level(level, &config)?;
if size.is_positive() {
let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
deltas.push(OrderBookDelta::new(
instrument_id,
BookAction::Add,
order,
RecordFlag::F_LAST as u8, order_id,
UnixNanos::from(data.time * 1_000_000),
ts_init,
));
order_id += 1;
}
}
Ok(OrderBookDeltas::new(instrument_id, deltas))
}
pub fn convert_ws_snapshot(
&self,
data: &WsBookData,
instrument_id: InstrumentId,
ts_init: UnixNanos,
) -> Result<OrderBookDeltas, ConversionError> {
let config = self.get_config(&data.coin);
let mut deltas = Vec::new();
deltas.push(OrderBookDelta::clear(
instrument_id,
0, UnixNanos::from(data.time * 1_000_000), ts_init,
));
let mut order_id = 1u64;
for level in &data.levels[0] {
let (price, size) = parse_ws_level(level, &config)?;
if size.is_positive() {
let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
deltas.push(OrderBookDelta::new(
instrument_id,
BookAction::Add,
order,
RecordFlag::F_LAST as u8,
order_id,
UnixNanos::from(data.time * 1_000_000),
ts_init,
));
order_id += 1;
}
}
for level in &data.levels[1] {
let (price, size) = parse_ws_level(level, &config)?;
if size.is_positive() {
let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
deltas.push(OrderBookDelta::new(
instrument_id,
BookAction::Add,
order,
RecordFlag::F_LAST as u8,
order_id,
UnixNanos::from(data.time * 1_000_000),
ts_init,
));
order_id += 1;
}
}
Ok(OrderBookDeltas::new(instrument_id, deltas))
}
#[allow(clippy::too_many_arguments)]
pub fn convert_delta_update(
&self,
instrument_id: InstrumentId,
sequence: u64,
ts_event: UnixNanos,
ts_init: UnixNanos,
bid_updates: &[(String, String)], ask_updates: &[(String, String)], bid_removals: &[String], ask_removals: &[String], ) -> Result<OrderBookDeltas, ConversionError> {
let config = self.get_config(&instrument_id.symbol.inner());
let mut deltas = Vec::new();
let mut order_id = sequence * 1000;
for price_str in bid_removals {
let price = parse_price(price_str, &config)?;
let order = BookOrder::new(OrderSide::Buy, price, Quantity::from("0"), order_id);
deltas.push(OrderBookDelta::new(
instrument_id,
BookAction::Delete,
order,
0, sequence,
ts_event,
ts_init,
));
order_id += 1;
}
for price_str in ask_removals {
let price = parse_price(price_str, &config)?;
let order = BookOrder::new(OrderSide::Sell, price, Quantity::from("0"), order_id);
deltas.push(OrderBookDelta::new(
instrument_id,
BookAction::Delete,
order,
0, sequence,
ts_event,
ts_init,
));
order_id += 1;
}
for (price_str, size_str) in bid_updates {
let price = parse_price(price_str, &config)?;
let size = parse_size(size_str, &config)?;
if size.is_positive() {
let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
deltas.push(OrderBookDelta::new(
instrument_id,
BookAction::Update, order,
0, sequence,
ts_event,
ts_init,
));
} else {
let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
deltas.push(OrderBookDelta::new(
instrument_id,
BookAction::Delete,
order,
0, sequence,
ts_event,
ts_init,
));
}
order_id += 1;
}
for (price_str, size_str) in ask_updates {
let price = parse_price(price_str, &config)?;
let size = parse_size(size_str, &config)?;
if size.is_positive() {
let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
deltas.push(OrderBookDelta::new(
instrument_id,
BookAction::Update, order,
0, sequence,
ts_event,
ts_init,
));
} else {
let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
deltas.push(OrderBookDelta::new(
instrument_id,
BookAction::Delete,
order,
0, sequence,
ts_event,
ts_init,
));
}
order_id += 1;
}
Ok(OrderBookDeltas::new(instrument_id, deltas))
}
}
fn parse_level(
level: &HyperliquidLevel,
inst_info: &HyperliquidInstrumentInfo,
) -> Result<(Price, Quantity), ConversionError> {
let price = parse_price(&level.px, inst_info)?;
let size = parse_size(&level.sz, inst_info)?;
Ok((price, size))
}
fn parse_ws_level(
level: &WsLevelData,
config: &HyperliquidInstrumentInfo,
) -> Result<(Price, Quantity), ConversionError> {
let price = parse_price(&level.px, config)?;
let size = parse_size(&level.sz, config)?;
Ok((price, size))
}
fn parse_price(
price_str: &str,
_config: &HyperliquidInstrumentInfo,
) -> Result<Price, ConversionError> {
let _decimal = Decimal::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
value: price_str.to_string(),
})?;
Price::from_str(price_str).map_err(|_| ConversionError::InvalidPrice {
value: price_str.to_string(),
})
}
fn parse_size(
size_str: &str,
_config: &HyperliquidInstrumentInfo,
) -> Result<Quantity, ConversionError> {
let _decimal = Decimal::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
value: size_str.to_string(),
})?;
Quantity::from_str(size_str).map_err(|_| ConversionError::InvalidSize {
value: size_str.to_string(),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConversionError {
InvalidPrice { value: String },
InvalidSize { value: String },
OrderBookDeltasError(String),
}
impl From<anyhow::Error> for ConversionError {
fn from(err: anyhow::Error) -> Self {
Self::OrderBookDeltasError(err.to_string())
}
}
impl Display for ConversionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidPrice { value } => write!(f, "Invalid price: {value}"),
Self::InvalidSize { value } => write!(f, "Invalid size: {value}"),
Self::OrderBookDeltasError(msg) => {
write!(f, "OrderBookDeltas error: {msg}")
}
}
}
}
impl std::error::Error for ConversionError {}
#[derive(Clone, Debug)]
pub struct HyperliquidPositionData {
pub asset: String,
pub position: Decimal, pub entry_px: Option<Decimal>,
pub unrealized_pnl: Decimal,
pub cumulative_funding: Option<Decimal>,
pub position_value: Decimal,
}
impl HyperliquidPositionData {
pub fn is_flat(&self) -> bool {
self.position.is_zero()
}
pub fn is_long(&self) -> bool {
self.position > Decimal::ZERO
}
pub fn is_short(&self) -> bool {
self.position < Decimal::ZERO
}
}
#[derive(Clone, Debug)]
pub struct HyperliquidBalance {
pub asset: String,
pub total: Decimal,
pub available: Decimal,
pub sequence: u64,
pub ts_event: UnixNanos,
}
impl HyperliquidBalance {
pub fn new(
asset: String,
total: Decimal,
available: Decimal,
sequence: u64,
ts_event: UnixNanos,
) -> Self {
Self {
asset,
total,
available,
sequence,
ts_event,
}
}
pub fn locked(&self) -> Decimal {
(self.total - self.available).max(Decimal::ZERO)
}
}
#[derive(Default, Debug)]
pub struct HyperliquidAccountState {
pub balances: AHashMap<String, HyperliquidBalance>,
pub last_sequence: u64,
}
impl HyperliquidAccountState {
pub fn new() -> Self {
Self::default()
}
pub fn get_balance(&self, asset: &str) -> HyperliquidBalance {
self.balances.get(asset).cloned().unwrap_or_else(|| {
HyperliquidBalance::new(
asset.to_string(),
Decimal::ZERO,
Decimal::ZERO,
0,
UnixNanos::default(),
)
})
}
pub fn account_value(&self) -> Decimal {
self.balances.values().map(|balance| balance.total).sum()
}
pub fn to_account_state(
&self,
account_id: AccountId,
ts_event: UnixNanos,
ts_init: UnixNanos,
) -> anyhow::Result<AccountState> {
let balances: Vec<AccountBalance> = self
.balances
.values()
.map(|balance| {
let currency = get_currency(&balance.asset);
let total = Money::from_decimal(balance.total, currency)?;
let free = Money::from_decimal(balance.available, currency)?;
let locked = total - free;
Ok(AccountBalance::new(total, locked, free))
})
.collect::<anyhow::Result<Vec<_>>>()?;
let margins = Vec::new();
let account_type = AccountType::Margin;
let is_reported = true;
let event_id = UUID4::new();
Ok(AccountState::new(
account_id,
account_type,
balances,
margins,
is_reported,
event_id,
ts_event,
ts_init,
None, ))
}
}
#[derive(Debug, Clone)]
pub enum HyperliquidAccountEvent {
BalanceSnapshot {
balances: Vec<HyperliquidBalance>,
sequence: u64,
},
BalanceDelta { balance: HyperliquidBalance },
}
impl HyperliquidAccountState {
pub fn apply(&mut self, event: HyperliquidAccountEvent) {
match event {
HyperliquidAccountEvent::BalanceSnapshot { balances, sequence } => {
self.balances.clear();
for balance in balances {
self.balances.insert(balance.asset.clone(), balance);
}
self.last_sequence = sequence;
}
HyperliquidAccountEvent::BalanceDelta { balance } => {
let sequence = balance.sequence;
let entry = self
.balances
.entry(balance.asset.clone())
.or_insert_with(|| balance.clone());
if sequence > entry.sequence {
*entry = balance;
self.last_sequence = self.last_sequence.max(sequence);
}
}
}
}
}
pub fn parse_position_status_report(
position_data: &HyperliquidPositionData,
account_id: AccountId,
instrument_id: InstrumentId,
ts_init: UnixNanos,
) -> anyhow::Result<PositionStatusReport> {
let position_side = if position_data.is_flat() {
PositionSide::Flat
} else if position_data.is_long() {
PositionSide::Long
} else {
PositionSide::Short
};
let quantity = Quantity::from_decimal(position_data.position.abs())?;
let ts_last = ts_init;
let avg_px_open = position_data.entry_px;
Ok(PositionStatusReport::new(
account_id,
instrument_id,
position_side.as_specified(),
quantity,
ts_last,
ts_init,
None, None, avg_px_open,
))
}
#[cfg(test)]
#[allow(dead_code)]
mod tests {
use rstest::rstest;
use rust_decimal_macros::dec;
use super::*;
use crate::common::testing::load_test_data;
fn test_instrument_id() -> InstrumentId {
InstrumentId::from("BTC.HYPER")
}
fn sample_http_book() -> HyperliquidL2Book {
load_test_data("http_l2_book_snapshot.json")
}
fn sample_ws_book() -> WsBookData {
load_test_data("ws_book_data.json")
}
#[rstest]
fn test_http_snapshot_conversion() {
let converter = HyperliquidDataConverter::new();
let book_data = sample_http_book();
let instrument_id = test_instrument_id();
let ts_init = UnixNanos::default();
let deltas = converter
.convert_http_snapshot(&book_data, instrument_id, ts_init)
.unwrap();
assert_eq!(deltas.instrument_id, instrument_id);
assert_eq!(deltas.deltas.len(), 11);
let clear_delta = &deltas.deltas[0];
assert_eq!(clear_delta.instrument_id, instrument_id);
assert_eq!(clear_delta.action, BookAction::Clear);
assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
assert_eq!(clear_delta.order.price.raw, 0);
assert_eq!(clear_delta.order.price.precision, 0);
assert_eq!(clear_delta.order.size.raw, 0);
assert_eq!(clear_delta.order.size.precision, 0);
assert_eq!(clear_delta.order.order_id, 0);
assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
assert_eq!(clear_delta.sequence, 0);
assert_eq!(
clear_delta.ts_event,
UnixNanos::from(book_data.time * 1_000_000)
);
assert_eq!(clear_delta.ts_init, ts_init);
let first_bid_delta = &deltas.deltas[1];
assert_eq!(first_bid_delta.instrument_id, instrument_id);
assert_eq!(first_bid_delta.action, BookAction::Add);
assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
assert_eq!(first_bid_delta.order.order_id, 1);
assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
assert_eq!(first_bid_delta.sequence, 1);
assert_eq!(
first_bid_delta.ts_event,
UnixNanos::from(book_data.time * 1_000_000)
);
assert_eq!(first_bid_delta.ts_init, ts_init);
for delta in &deltas.deltas[1..] {
assert_eq!(delta.action, BookAction::Add);
assert!(delta.order.size.is_positive());
}
}
#[rstest]
fn test_ws_snapshot_conversion() {
let converter = HyperliquidDataConverter::new();
let book_data = sample_ws_book();
let instrument_id = test_instrument_id();
let ts_init = UnixNanos::default();
let deltas = converter
.convert_ws_snapshot(&book_data, instrument_id, ts_init)
.unwrap();
assert_eq!(deltas.instrument_id, instrument_id);
assert_eq!(deltas.deltas.len(), 11);
let clear_delta = &deltas.deltas[0];
assert_eq!(clear_delta.instrument_id, instrument_id);
assert_eq!(clear_delta.action, BookAction::Clear);
assert_eq!(clear_delta.order.side, OrderSide::NoOrderSide);
assert_eq!(clear_delta.order.price.raw, 0);
assert_eq!(clear_delta.order.price.precision, 0);
assert_eq!(clear_delta.order.size.raw, 0);
assert_eq!(clear_delta.order.size.precision, 0);
assert_eq!(clear_delta.order.order_id, 0);
assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
assert_eq!(clear_delta.sequence, 0);
assert_eq!(
clear_delta.ts_event,
UnixNanos::from(book_data.time * 1_000_000)
);
assert_eq!(clear_delta.ts_init, ts_init);
let first_bid_delta = &deltas.deltas[1];
assert_eq!(first_bid_delta.instrument_id, instrument_id);
assert_eq!(first_bid_delta.action, BookAction::Add);
assert_eq!(first_bid_delta.order.side, OrderSide::Buy);
assert_eq!(first_bid_delta.order.price, Price::from("98450.50"));
assert_eq!(first_bid_delta.order.size, Quantity::from("2.5"));
assert_eq!(first_bid_delta.order.order_id, 1);
assert_eq!(first_bid_delta.flags, RecordFlag::F_LAST as u8);
assert_eq!(first_bid_delta.sequence, 1);
assert_eq!(
first_bid_delta.ts_event,
UnixNanos::from(book_data.time * 1_000_000)
);
assert_eq!(first_bid_delta.ts_init, ts_init);
}
#[rstest]
fn test_delta_update_conversion() {
let converter = HyperliquidDataConverter::new();
let instrument_id = test_instrument_id();
let ts_event = UnixNanos::default();
let ts_init = UnixNanos::default();
let bid_updates = vec![("98450.00".to_string(), "1.5".to_string())];
let ask_updates = vec![("98451.00".to_string(), "2.0".to_string())];
let bid_removals = vec!["98449.00".to_string()];
let ask_removals = vec!["98452.00".to_string()];
let deltas = converter
.convert_delta_update(
instrument_id,
123,
ts_event,
ts_init,
&bid_updates,
&ask_updates,
&bid_removals,
&ask_removals,
)
.unwrap();
assert_eq!(deltas.instrument_id, instrument_id);
assert_eq!(deltas.deltas.len(), 4); assert_eq!(deltas.sequence, 123);
let first_delta = &deltas.deltas[0];
assert_eq!(first_delta.instrument_id, instrument_id);
assert_eq!(first_delta.action, BookAction::Delete);
assert_eq!(first_delta.order.side, OrderSide::Buy);
assert_eq!(first_delta.order.price, Price::from("98449.00"));
assert_eq!(first_delta.order.size, Quantity::from("0"));
assert_eq!(first_delta.order.order_id, 123000);
assert_eq!(first_delta.flags, 0);
assert_eq!(first_delta.sequence, 123);
assert_eq!(first_delta.ts_event, ts_event);
assert_eq!(first_delta.ts_init, ts_init);
}
#[rstest]
fn test_price_size_parsing() {
let instrument_id = test_instrument_id();
let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
let price = parse_price("98450.50", &config).unwrap();
assert_eq!(price.to_string(), "98450.50");
let size = parse_size("2.5", &config).unwrap();
assert_eq!(size.to_string(), "2.5");
}
#[rstest]
fn test_hyperliquid_instrument_mini_info() {
let instrument_id = test_instrument_id();
let config = HyperliquidInstrumentInfo::new(instrument_id, 4, 6);
assert_eq!(config.instrument_id, instrument_id);
assert_eq!(config.price_decimals, 4);
assert_eq!(config.size_decimals, 6);
let default_config = HyperliquidInstrumentInfo::default_crypto(instrument_id);
assert_eq!(default_config.instrument_id, instrument_id);
assert_eq!(default_config.price_decimals, 2);
assert_eq!(default_config.size_decimals, 5);
}
#[rstest]
fn test_invalid_price_parsing() {
let instrument_id = test_instrument_id();
let config = HyperliquidInstrumentInfo::new(instrument_id, 2, 5);
let result = parse_price("invalid", &config);
assert!(result.is_err());
match result.unwrap_err() {
ConversionError::InvalidPrice { value } => {
assert_eq!(value, "invalid");
assert!(value.contains("invalid"));
}
_ => panic!("Expected InvalidPrice error"),
}
let size_result = parse_size("not_a_number", &config);
assert!(size_result.is_err());
match size_result.unwrap_err() {
ConversionError::InvalidSize { value } => {
assert_eq!(value, "not_a_number");
assert!(value.contains("not_a_number"));
}
_ => panic!("Expected InvalidSize error"),
}
}
#[rstest]
fn test_configuration() {
let mut converter = HyperliquidDataConverter::new();
let eth_id = InstrumentId::from("ETH.HYPER");
let config = HyperliquidInstrumentInfo::new(eth_id, 4, 8);
let asset = Ustr::from("ETH");
converter.configure_instrument(asset.as_str(), config.clone());
let retrieved_config = converter.get_config(&asset);
assert_eq!(retrieved_config.instrument_id, eth_id);
assert_eq!(retrieved_config.price_decimals, 4);
assert_eq!(retrieved_config.size_decimals, 8);
let default_config = converter.get_config(&Ustr::from("UNKNOWN"));
assert_eq!(
default_config.instrument_id,
InstrumentId::from("UNKNOWN.HYPER")
);
assert_eq!(default_config.price_decimals, 2);
assert_eq!(default_config.size_decimals, 5);
assert_eq!(config.instrument_id, eth_id);
assert_eq!(config.price_decimals, 4);
assert_eq!(config.size_decimals, 8);
}
#[rstest]
fn test_instrument_info_creation() {
let instrument_id = InstrumentId::from("BTC.HYPER");
let info = HyperliquidInstrumentInfo::with_metadata(
instrument_id,
2,
5,
dec!(0.01),
dec!(0.00001),
dec!(10),
);
assert_eq!(info.instrument_id, instrument_id);
assert_eq!(info.price_decimals, 2);
assert_eq!(info.size_decimals, 5);
assert_eq!(info.tick_size, Some(dec!(0.01)));
assert_eq!(info.step_size, Some(dec!(0.00001)));
assert_eq!(info.min_notional, Some(dec!(10)));
}
#[rstest]
fn test_instrument_info_with_precision() {
let instrument_id = test_instrument_id();
let info = HyperliquidInstrumentInfo::with_precision(instrument_id, 3, 4);
assert_eq!(info.instrument_id, instrument_id);
assert_eq!(info.price_decimals, 3);
assert_eq!(info.size_decimals, 4);
assert_eq!(info.tick_size, Some(dec!(0.001))); assert_eq!(info.step_size, Some(dec!(0.0001))); }
#[tokio::test]
async fn test_instrument_cache_basic_operations() {
let btc_info = HyperliquidInstrumentInfo::with_metadata(
InstrumentId::from("BTC.HYPER"),
2,
5,
dec!(0.01),
dec!(0.00001),
dec!(10),
);
let eth_info = HyperliquidInstrumentInfo::with_metadata(
InstrumentId::from("ETH.HYPER"),
2,
4,
dec!(0.01),
dec!(0.0001),
dec!(10),
);
let mut cache = HyperliquidInstrumentCache::new();
cache.insert("BTC", btc_info.clone());
cache.insert("ETH", eth_info.clone());
let retrieved_btc = cache.get("BTC").unwrap();
assert_eq!(retrieved_btc.instrument_id, btc_info.instrument_id);
assert_eq!(retrieved_btc.size_decimals, 5);
let retrieved_eth = cache.get("ETH").unwrap();
assert_eq!(retrieved_eth.instrument_id, eth_info.instrument_id);
assert_eq!(retrieved_eth.size_decimals, 4);
assert_eq!(cache.len(), 2);
assert!(!cache.is_empty());
assert!(cache.contains("BTC"));
assert!(cache.contains("ETH"));
assert!(!cache.contains("UNKNOWN"));
let all_instruments = cache.get_all();
assert_eq!(all_instruments.len(), 2);
}
#[rstest]
fn test_instrument_cache_empty() {
let cache = HyperliquidInstrumentCache::new();
let result = cache.get("UNKNOWN");
assert!(result.is_none());
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
}
#[rstest]
fn test_normalize_order_for_symbol() {
use rust_decimal_macros::dec;
let mut converter = HyperliquidDataConverter::new();
let btc_info = HyperliquidInstrumentInfo::with_metadata(
InstrumentId::from("BTC.HYPER"),
2,
5,
dec!(0.01), dec!(0.00001), dec!(10.0), );
converter.configure_instrument("BTC", btc_info);
let result = converter.normalize_order_for_symbol(
"BTC",
dec!(50123.456789), dec!(0.123456789), );
assert!(result.is_ok());
let (price, qty) = result.unwrap();
assert_eq!(price, dec!(50123.00));
assert_eq!(qty, dec!(0.12345));
let result_eth = converter.normalize_order_for_symbol("ETH", dec!(3000.123), dec!(1.23456));
assert!(result_eth.is_ok());
let result_fail = converter.normalize_order_for_symbol(
"BTC",
dec!(1.0), dec!(0.001), );
assert!(result_fail.is_err());
assert!(result_fail.unwrap_err().contains("Notional value"));
}
#[rstest]
fn test_hyperliquid_balance_creation_and_properties() {
use rust_decimal_macros::dec;
let asset = "USD".to_string();
let total = dec!(1000.0);
let available = dec!(750.0);
let sequence = 42;
let ts_event = UnixNanos::default();
let balance = HyperliquidBalance::new(asset.clone(), total, available, sequence, ts_event);
assert_eq!(balance.asset, asset);
assert_eq!(balance.total, total);
assert_eq!(balance.available, available);
assert_eq!(balance.sequence, sequence);
assert_eq!(balance.ts_event, ts_event);
assert_eq!(balance.locked(), dec!(250.0));
let full_balance = HyperliquidBalance::new(
"ETH".to_string(),
dec!(100.0),
dec!(100.0),
1,
UnixNanos::default(),
);
assert_eq!(full_balance.locked(), dec!(0.0));
let weird_balance = HyperliquidBalance::new(
"WEIRD".to_string(),
dec!(50.0),
dec!(60.0),
1,
UnixNanos::default(),
);
assert_eq!(weird_balance.locked(), dec!(0.0));
}
#[rstest]
fn test_hyperliquid_account_state_creation() {
let state = HyperliquidAccountState::new();
assert!(state.balances.is_empty());
assert_eq!(state.last_sequence, 0);
let default_state = HyperliquidAccountState::default();
assert!(default_state.balances.is_empty());
assert_eq!(default_state.last_sequence, 0);
}
#[rstest]
fn test_hyperliquid_account_state_getters() {
use rust_decimal_macros::dec;
let mut state = HyperliquidAccountState::new();
let balance = state.get_balance("USD");
assert_eq!(balance.asset, "USD");
assert_eq!(balance.total, dec!(0.0));
assert_eq!(balance.available, dec!(0.0));
let real_balance = HyperliquidBalance::new(
"USD".to_string(),
dec!(1000.0),
dec!(750.0),
1,
UnixNanos::default(),
);
state.balances.insert("USD".to_string(), real_balance);
let retrieved_balance = state.get_balance("USD");
assert_eq!(retrieved_balance.total, dec!(1000.0));
}
#[rstest]
fn test_hyperliquid_account_state_account_value() {
use rust_decimal_macros::dec;
let mut state = HyperliquidAccountState::new();
state.balances.insert(
"USD".to_string(),
HyperliquidBalance::new(
"USD".to_string(),
dec!(10000.0),
dec!(5000.0),
1,
UnixNanos::default(),
),
);
let total_value = state.account_value();
assert_eq!(total_value, dec!(10000.0));
state.balances.clear();
let no_balance_value = state.account_value();
assert_eq!(no_balance_value, dec!(0.0));
}
#[rstest]
fn test_hyperliquid_account_event_balance_snapshot() {
use rust_decimal_macros::dec;
let mut state = HyperliquidAccountState::new();
let balance = HyperliquidBalance::new(
"USD".to_string(),
dec!(1000.0),
dec!(750.0),
10,
UnixNanos::default(),
);
let snapshot_event = HyperliquidAccountEvent::BalanceSnapshot {
balances: vec![balance],
sequence: 10,
};
state.apply(snapshot_event);
assert_eq!(state.balances.len(), 1);
assert_eq!(state.last_sequence, 10);
assert_eq!(state.get_balance("USD").total, dec!(1000.0));
}
#[rstest]
fn test_hyperliquid_account_event_balance_delta() {
use rust_decimal_macros::dec;
let mut state = HyperliquidAccountState::new();
let initial_balance = HyperliquidBalance::new(
"USD".to_string(),
dec!(1000.0),
dec!(750.0),
5,
UnixNanos::default(),
);
state.balances.insert("USD".to_string(), initial_balance);
state.last_sequence = 5;
let updated_balance = HyperliquidBalance::new(
"USD".to_string(),
dec!(1200.0),
dec!(900.0),
10,
UnixNanos::default(),
);
let delta_event = HyperliquidAccountEvent::BalanceDelta {
balance: updated_balance,
};
state.apply(delta_event);
let balance = state.get_balance("USD");
assert_eq!(balance.total, dec!(1200.0));
assert_eq!(balance.available, dec!(900.0));
assert_eq!(balance.sequence, 10);
assert_eq!(state.last_sequence, 10);
let old_balance = HyperliquidBalance::new(
"USD".to_string(),
dec!(800.0),
dec!(600.0),
8,
UnixNanos::default(),
);
let old_delta_event = HyperliquidAccountEvent::BalanceDelta {
balance: old_balance,
};
state.apply(old_delta_event);
let balance = state.get_balance("USD");
assert_eq!(balance.total, dec!(1200.0)); assert_eq!(balance.sequence, 10); assert_eq!(state.last_sequence, 10); }
}