use anyhow::Context;
use nautilus_core::{UUID4, UnixNanos};
use nautilus_model::{
enums::{
CurrencyType, LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSideSpecified,
TimeInForce, TriggerType,
},
identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
reports::{FillReport, OrderStatusReport, PositionStatusReport},
types::{Currency, Money, Price, Quantity},
};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use ustr::Ustr;
use super::models::{AssetPosition, HyperliquidFill, PerpMeta, SpotMeta};
use crate::{
common::{
consts::HYPERLIQUID_VENUE,
enums::{
HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide, HyperliquidTpSl,
},
parse::make_fill_trade_id,
},
websocket::messages::{WsBasicOrderData, WsOrderData},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HyperliquidMarketType {
Perp,
Spot,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HyperliquidInstrumentDef {
pub symbol: Ustr,
pub raw_symbol: Ustr,
pub base: Ustr,
pub quote: Ustr,
pub market_type: HyperliquidMarketType,
pub asset_index: u32,
pub price_decimals: u32,
pub size_decimals: u32,
pub tick_size: Decimal,
pub lot_size: Decimal,
pub max_leverage: Option<u32>,
pub only_isolated: bool,
pub is_hip3: bool,
pub active: bool,
pub raw_data: String,
}
pub fn parse_perp_instruments(
meta: &PerpMeta,
asset_index_base: u32,
) -> Result<Vec<HyperliquidInstrumentDef>, String> {
const PERP_MAX_DECIMALS: i32 = 6;
let mut defs = Vec::new();
for (index, asset) in meta.universe.iter().enumerate() {
let is_delisted = asset.is_delisted.unwrap_or(false);
let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
let tick_size = pow10_neg(price_decimals);
let lot_size = pow10_neg(asset.sz_decimals);
let symbol = format!("{}-USD-PERP", asset.name);
let raw_symbol: Ustr = asset.name.as_str().into();
let def = HyperliquidInstrumentDef {
symbol: symbol.into(),
raw_symbol,
base: asset.name.clone().into(),
quote: "USD".into(),
market_type: HyperliquidMarketType::Perp,
asset_index: asset_index_base + index as u32,
price_decimals,
size_decimals: asset.sz_decimals,
tick_size,
lot_size,
max_leverage: asset.max_leverage,
only_isolated: asset.only_isolated.unwrap_or(false),
is_hip3: asset_index_base > 0,
active: !is_delisted,
raw_data: serde_json::to_string(asset).unwrap_or_default(),
};
defs.push(def);
}
Ok(defs)
}
pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
const SPOT_MAX_DECIMALS: i32 = 8; const SPOT_INDEX_OFFSET: u32 = 10000;
let mut defs = Vec::new();
let mut tokens_by_index = ahash::AHashMap::new();
for token in &meta.tokens {
tokens_by_index.insert(token.index, token);
}
for pair in &meta.universe {
let base_token = tokens_by_index
.get(&pair.tokens[0])
.ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
let quote_token = tokens_by_index
.get(&pair.tokens[1])
.ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
let tick_size = pow10_neg(price_decimals);
let lot_size = pow10_neg(base_token.sz_decimals);
let symbol = format!("{}-{}-SPOT", base_token.name, quote_token.name);
let raw_symbol: Ustr = if base_token.name == "PURR" {
pair.name.as_str().into()
} else {
format!("@{}", pair.index).into()
};
let def = HyperliquidInstrumentDef {
symbol: symbol.into(),
raw_symbol,
base: base_token.name.clone().into(),
quote: quote_token.name.clone().into(),
market_type: HyperliquidMarketType::Spot,
asset_index: SPOT_INDEX_OFFSET + pair.index,
price_decimals,
size_decimals: base_token.sz_decimals,
tick_size,
lot_size,
max_leverage: None,
only_isolated: false,
is_hip3: false,
active: pair.is_canonical, raw_data: serde_json::to_string(pair).unwrap_or_default(),
};
defs.push(def);
}
Ok(defs)
}
fn pow10_neg(decimals: u32) -> Decimal {
if decimals == 0 {
return Decimal::ONE;
}
Decimal::from_i128_with_scale(1, decimals)
}
pub fn get_currency(code: &str) -> Currency {
Currency::try_from_str(code).unwrap_or_else(|| {
let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
if let Err(e) = Currency::register(currency, false) {
log::error!("Failed to register currency '{code}': {e}");
}
currency
})
}
#[must_use]
pub fn create_instrument_from_def(
def: &HyperliquidInstrumentDef,
ts_init: UnixNanos,
) -> Option<InstrumentAny> {
let symbol = Symbol::new(def.symbol);
let venue = *HYPERLIQUID_VENUE;
let instrument_id = InstrumentId::new(symbol, venue);
let raw_symbol = Symbol::new(def.raw_symbol);
let base_currency = get_currency(&def.base);
let quote_currency = get_currency(&def.quote);
let price_increment = Price::from(def.tick_size.to_string());
let size_increment = Quantity::from(def.lot_size.to_string());
match def.market_type {
HyperliquidMarketType::Spot => Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
instrument_id,
raw_symbol,
base_currency,
quote_currency,
def.price_decimals as u8,
def.size_decimals as u8,
price_increment,
size_increment,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
ts_init, ts_init,
))),
HyperliquidMarketType::Perp => {
let settlement_currency = get_currency("USDC");
Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
instrument_id,
raw_symbol,
base_currency,
quote_currency,
settlement_currency,
false,
def.price_decimals as u8,
def.size_decimals as u8,
price_increment,
size_increment,
None, None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
ts_init, ts_init,
)))
}
}
}
#[must_use]
pub fn instruments_from_defs(
defs: &[HyperliquidInstrumentDef],
ts_init: UnixNanos,
) -> Vec<InstrumentAny> {
defs.iter()
.filter_map(|def| create_instrument_from_def(def, ts_init))
.collect()
}
#[must_use]
pub fn instruments_from_defs_owned(
defs: Vec<HyperliquidInstrumentDef>,
ts_init: UnixNanos,
) -> Vec<InstrumentAny> {
defs.into_iter()
.filter_map(|def| create_instrument_from_def(&def, ts_init))
.collect()
}
fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
match side {
HyperliquidSide::Buy => OrderSide::Buy,
HyperliquidSide::Sell => OrderSide::Sell,
}
}
pub fn parse_order_status_report_from_ws(
order_data: &WsOrderData,
instrument: &dyn Instrument,
account_id: AccountId,
ts_init: UnixNanos,
) -> anyhow::Result<OrderStatusReport> {
parse_order_status_report_from_basic(
&order_data.order,
&order_data.status,
instrument,
account_id,
ts_init,
)
}
pub fn parse_order_status_report_from_basic(
order: &WsBasicOrderData,
status: &HyperliquidOrderStatusEnum,
instrument: &dyn Instrument,
account_id: AccountId,
ts_init: UnixNanos,
) -> anyhow::Result<OrderStatusReport> {
let instrument_id = instrument.id();
let venue_order_id = VenueOrderId::new(order.oid.to_string());
let order_side = OrderSide::from(order.side);
let order_type = if order.trigger_px.is_some() {
if order.is_market == Some(true) {
match order.tpsl.as_ref() {
Some(HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
Some(HyperliquidTpSl::Sl) => OrderType::StopMarket,
_ => OrderType::StopMarket,
}
} else {
match order.tpsl.as_ref() {
Some(HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
Some(HyperliquidTpSl::Sl) => OrderType::StopLimit,
_ => OrderType::StopLimit,
}
}
} else {
OrderType::Limit
};
let time_in_force = TimeInForce::Gtc;
let order_status = OrderStatus::from(*status);
let price_precision = instrument.price_precision();
let size_precision = instrument.size_precision();
let orig_sz: Decimal = order
.orig_sz
.parse()
.map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
let current_sz: Decimal = order
.sz
.parse()
.map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
.map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
let filled_sz = orig_sz.abs() - current_sz.abs();
let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
.map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
let ts_last = ts_accepted;
let report_id = UUID4::new();
let mut report = OrderStatusReport::new(
account_id,
instrument_id,
None, venue_order_id,
order_side,
order_type,
time_in_force,
order_status,
quantity,
filled_qty,
ts_accepted,
ts_last,
ts_init,
Some(report_id),
);
if let Some(cloid) = &order.cloid {
report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
}
if !matches!(
order_status,
OrderStatus::Filled | OrderStatus::PartiallyFilled
) {
let limit_px: Decimal = order
.limit_px
.parse()
.map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
let price = Price::from_decimal_dp(limit_px, price_precision)
.map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
report = report.with_price(price);
}
if let Some(trigger_px) = &order.trigger_px {
let trig_px: Decimal = trigger_px
.parse()
.map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
let trigger_price = Price::from_decimal_dp(trig_px, price_precision)
.map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
report = report
.with_trigger_price(trigger_price)
.with_trigger_type(TriggerType::Default);
}
Ok(report)
}
pub fn parse_fill_report(
fill: &HyperliquidFill,
instrument: &dyn Instrument,
account_id: AccountId,
ts_init: UnixNanos,
) -> anyhow::Result<FillReport> {
let instrument_id = instrument.id();
let venue_order_id = VenueOrderId::new(fill.oid.to_string());
let trade_id = make_fill_trade_id(
&fill.hash,
fill.oid,
&fill.px,
&fill.sz,
fill.time,
&fill.start_position,
);
let order_side = parse_fill_side(&fill.side);
let price_precision = instrument.price_precision();
let size_precision = instrument.size_precision();
let px: Decimal = fill
.px
.parse()
.map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
let sz: Decimal = fill
.sz
.parse()
.map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
let last_px = Price::from_decimal_dp(px, price_precision)
.map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
let last_qty = Quantity::from_decimal_dp(sz.abs(), size_precision)
.map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
let fee_amount: Decimal = fill
.fee
.parse()
.map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
let fee_currency: Currency = fill
.fee_token
.parse()
.map_err(|e| anyhow::anyhow!("Unknown fee token '{}': {e}", fill.fee_token))?;
let commission = Money::from_decimal(fee_amount, fee_currency)
.map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
let liquidity_side = if fill.crossed {
LiquiditySide::Taker
} else {
LiquiditySide::Maker
};
let ts_event = UnixNanos::from(fill.time * 1_000_000);
let report_id = UUID4::new();
let report = FillReport::new(
account_id,
instrument_id,
venue_order_id,
trade_id,
order_side,
last_qty,
last_px,
commission,
liquidity_side,
None, None, ts_event,
ts_init,
Some(report_id),
);
Ok(report)
}
pub fn parse_position_status_report(
position_data: &serde_json::Value,
instrument: &dyn Instrument,
account_id: AccountId,
ts_init: UnixNanos,
) -> anyhow::Result<PositionStatusReport> {
let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
.context("failed to deserialize AssetPosition")?;
let position = &asset_position.position;
let instrument_id = instrument.id();
let (position_side, quantity_value) = if position.szi.is_zero() {
(PositionSideSpecified::Flat, Decimal::ZERO)
} else if position.szi.is_sign_positive() {
(PositionSideSpecified::Long, position.szi)
} else {
(PositionSideSpecified::Short, position.szi.abs())
};
let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
.context("failed to create quantity from decimal")?;
let report_id = UUID4::new();
let ts_last = ts_init;
let avg_px_open = position.entry_px;
Ok(PositionStatusReport::new(
account_id,
instrument_id,
position_side,
quantity,
ts_last,
ts_init,
Some(report_id),
None, avg_px_open,
))
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use rust_decimal_macros::dec;
use super::{
super::models::{HyperliquidL2Book, PerpAsset, SpotPair, SpotToken},
*,
};
#[rstest]
fn test_parse_fill_side() {
assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
}
#[rstest]
fn test_pow10_neg() {
assert_eq!(pow10_neg(0), dec!(1));
assert_eq!(pow10_neg(1), dec!(0.1));
assert_eq!(pow10_neg(5), dec!(0.00001));
}
#[rstest]
fn test_parse_perp_instruments() {
let meta = PerpMeta {
universe: vec![
PerpAsset {
name: "BTC".to_string(),
sz_decimals: 5,
max_leverage: Some(50),
..Default::default()
},
PerpAsset {
name: "DELIST".to_string(),
sz_decimals: 3,
max_leverage: Some(10),
only_isolated: Some(true),
is_delisted: Some(true),
..Default::default()
},
],
margin_tables: vec![],
};
let defs = parse_perp_instruments(&meta, 0).unwrap();
assert_eq!(defs.len(), 2);
let btc = &defs[0];
assert_eq!(btc.symbol, "BTC-USD-PERP");
assert_eq!(btc.base, "BTC");
assert_eq!(btc.quote, "USD");
assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
assert_eq!(btc.tick_size, dec!(0.1));
assert_eq!(btc.lot_size, dec!(0.00001));
assert_eq!(btc.max_leverage, Some(50));
assert!(!btc.only_isolated);
assert!(btc.active);
let delist = &defs[1];
assert_eq!(delist.symbol, "DELIST-USD-PERP");
assert_eq!(delist.base, "DELIST");
assert!(!delist.active); }
use crate::common::testing::load_test_data;
#[rstest]
fn test_parse_perp_instruments_from_real_data() {
let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
let defs = parse_perp_instruments(&meta, 0).unwrap();
assert_eq!(defs.len(), 3);
let btc = &defs[0];
assert_eq!(btc.symbol, "BTC-USD-PERP");
assert_eq!(btc.base, "BTC");
assert_eq!(btc.quote, "USD");
assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
assert_eq!(btc.size_decimals, 5);
assert_eq!(btc.max_leverage, Some(40));
assert!(btc.active);
let eth = &defs[1];
assert_eq!(eth.symbol, "ETH-USD-PERP");
assert_eq!(eth.base, "ETH");
assert_eq!(eth.size_decimals, 4);
assert_eq!(eth.max_leverage, Some(25));
let atom = &defs[2];
assert_eq!(atom.symbol, "ATOM-USD-PERP");
assert_eq!(atom.base, "ATOM");
assert_eq!(atom.size_decimals, 2);
assert_eq!(atom.max_leverage, Some(5));
}
#[rstest]
fn test_deserialize_l2_book_from_real_data() {
let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
assert_eq!(book.coin, "BTC");
assert_eq!(book.levels.len(), 2); assert_eq!(book.levels[0].len(), 5); assert_eq!(book.levels[1].len(), 5);
let bids = &book.levels[0];
let asks = &book.levels[1];
for i in 1..bids.len() {
let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
let curr_price = bids[i].px.parse::<f64>().unwrap();
assert!(prev_price >= curr_price, "Bids should be descending");
}
for i in 1..asks.len() {
let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
let curr_price = asks[i].px.parse::<f64>().unwrap();
assert!(prev_price <= curr_price, "Asks should be ascending");
}
}
#[rstest]
fn test_parse_spot_instruments() {
let tokens = vec![
SpotToken {
name: "USDC".to_string(),
sz_decimals: 6,
wei_decimals: 6,
index: 0,
token_id: "0x1".to_string(),
is_canonical: true,
evm_contract: None,
full_name: None,
deployer_trading_fee_share: None,
},
SpotToken {
name: "PURR".to_string(),
sz_decimals: 0,
wei_decimals: 5,
index: 1,
token_id: "0x2".to_string(),
is_canonical: true,
evm_contract: None,
full_name: None,
deployer_trading_fee_share: None,
},
];
let pairs = vec![
SpotPair {
name: "PURR/USDC".to_string(),
tokens: [1, 0], index: 0,
is_canonical: true,
},
SpotPair {
name: "ALIAS".to_string(),
tokens: [1, 0],
index: 1,
is_canonical: false, },
];
let meta = SpotMeta {
tokens,
universe: pairs,
};
let defs = parse_spot_instruments(&meta).unwrap();
assert_eq!(defs.len(), 2);
let purr_usdc = &defs[0];
assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
assert_eq!(purr_usdc.base, "PURR");
assert_eq!(purr_usdc.quote, "USDC");
assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
assert_eq!(purr_usdc.lot_size, dec!(1));
assert_eq!(purr_usdc.max_leverage, None);
assert!(!purr_usdc.only_isolated);
assert!(purr_usdc.active);
let alias = &defs[1];
assert_eq!(alias.symbol, "PURR-USDC-SPOT");
assert_eq!(alias.base, "PURR");
assert!(!alias.active); }
#[rstest]
fn test_price_decimals_clamping() {
let meta = PerpMeta {
universe: vec![PerpAsset {
name: "HIGHPREC".to_string(),
sz_decimals: 10, max_leverage: Some(1),
..Default::default()
}],
margin_tables: vec![],
};
let defs = parse_perp_instruments(&meta, 0).unwrap();
assert_eq!(defs[0].price_decimals, 0);
assert_eq!(defs[0].tick_size, dec!(1));
}
#[rstest]
fn test_parse_perp_instruments_hip3_dex() {
let meta = PerpMeta {
universe: vec![
PerpAsset {
name: "xyz:TSLA".to_string(),
sz_decimals: 3,
max_leverage: Some(10),
only_isolated: None,
is_delisted: None,
growth_mode: Some("enabled".to_string()),
margin_mode: Some("strictIsolated".to_string()),
},
PerpAsset {
name: "xyz:NVDA".to_string(),
sz_decimals: 3,
max_leverage: Some(20),
only_isolated: None,
is_delisted: None,
growth_mode: None,
margin_mode: None,
},
],
margin_tables: vec![],
};
let defs = parse_perp_instruments(&meta, 110_000).unwrap();
assert_eq!(defs.len(), 2);
assert_eq!(defs[0].symbol, "xyz:TSLA-USD-PERP");
assert!(defs[0].symbol.contains(':'));
assert_eq!(defs[0].base, "xyz:TSLA");
assert_eq!(defs[0].asset_index, 110_000);
assert!(defs[0].active);
assert_eq!(defs[1].symbol, "xyz:NVDA-USD-PERP");
assert_eq!(defs[1].asset_index, 110_001);
}
}