use crate::client::hyperliquid::error::{map_sdk_error, order_rejection};
use crate::error::{OrderError, UnindexedClientError};
use crate::order::{
Order, OrderKey, OrderKind, TimeInForce, UnindexedOrderSnapshot,
id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
state::{Cancelled, Filled, Open, OrderState},
};
use crate::{
InstrumentAccountSnapshot, UnindexedAccountEvent,
balance::{AssetBalance, Balance},
position::PositionReport,
};
use chrono::{DateTime, TimeZone, Utc};
use fnv::FnvHashSet;
use futures::Stream;
use hyperliquid_rust_sdk::{ExchangeDataStatus, ExchangeResponseStatus};
use rust_decimal::Decimal;
use rustrade_instrument::{
Side,
asset::name::AssetNameExchange,
exchange::ExchangeId,
hyperliquid::{CoinKind, SpotPair},
instrument::name::InstrumentNameExchange,
};
use serde::de::DeserializeOwned;
use smol_str::format_smolstr;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::pin::Pin;
use std::str::FromStr;
use std::task::{Context, Poll};
use tokio_util::sync::CancellationToken;
use tracing::warn;
use uuid::Uuid;
#[derive(Debug)]
pub struct CancelOnDropStream<S> {
inner: S,
cancel_token: CancellationToken,
}
impl<S> CancelOnDropStream<S> {
pub(crate) fn new(inner: S, cancel_token: CancellationToken) -> Self {
Self {
inner,
cancel_token,
}
}
}
impl<S: Stream + Unpin> Stream for CancelOnDropStream<S> {
type Item = S::Item;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Pin::new(&mut self.inner).poll_next(cx)
}
}
impl<S> Drop for CancelOnDropStream<S> {
fn drop(&mut self) {
self.cancel_token.cancel();
}
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserFill {
pub coin: String,
pub side: String,
pub px: String,
pub sz: String,
pub time: u64,
pub oid: u64,
pub fee: String,
pub tid: u64,
pub fee_token: Option<String>,
pub cloid: Option<String>,
}
pub(super) fn span_millis(start: DateTime<Utc>, end: DateTime<Utc>) -> Option<(u64, u64)> {
if start > end {
return None;
}
let end_ms = u64::try_from(end.timestamp_millis()).ok()?;
let start_ms = u64::try_from(start.timestamp_millis()).unwrap_or(0);
Some((start_ms, end_ms))
}
const USER_FILLS_PER_RESPONSE: usize = 2_000;
const FILLS_RETAINED: usize = 10_000;
pub(super) fn held_since(fills: &[UserFill], start_ms: u64) -> Option<DateTime<Utc>> {
if fills.len() < FILLS_RETAINED {
return None;
}
let oldest = fills.iter().map(|fill| fill.time).min()?;
(oldest > start_ms).then(|| {
Utc.timestamp_millis_opt(i64::try_from(oldest).ok()?)
.single()
})?
}
pub async fn user_fills_by_time(
info_client: &hyperliquid_rust_sdk::InfoClient,
address: ethers::types::H160,
start_ms: u64,
end_ms: u64,
) -> Result<Vec<UserFill>, UnindexedClientError> {
let mut fills: Vec<UserFill> = Vec::new();
let mut from = start_ms;
loop {
let body = format!(
r#"{{"type":"userFillsByTime","user":"{address:?}","startTime":{from},"endTime":{end_ms}}}"#
);
let page: Vec<UserFill> = info(info_client, "userFillsByTime", body).await?;
let full = page.len() >= USER_FILLS_PER_RESPONSE;
let Some(last_time) = page.last().map(|fill| fill.time) else {
break;
};
let seen: Vec<(String, u64)> = fills
.iter()
.rev()
.take_while(|fill| fill.time == from)
.map(|fill| (fill.coin.clone(), fill.tid))
.collect();
fills.extend(page.into_iter().filter(|fill| {
fill.time != from
|| !seen
.iter()
.any(|(coin, tid)| *tid == fill.tid && *coin == fill.coin)
}));
if !full {
break;
}
if last_time <= from {
return Err(UnindexedClientError::Truncated {
fills_read: fills.len(),
});
}
from = last_time;
}
Ok(fills)
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenOrder {
pub coin: String,
pub side: String,
pub limit_px: String,
pub sz: String,
pub oid: u64,
pub timestamp: u64,
#[serde(default)]
pub orig_sz: Option<String>,
#[serde(default)]
pub cloid: Option<String>,
}
pub async fn open_orders(
info_client: &hyperliquid_rust_sdk::InfoClient,
address: ethers::types::H160,
) -> Result<Vec<OpenOrder>, UnindexedClientError> {
user_info(info_client, "openOrders", address, None).await
}
pub(super) async fn user_info<T: DeserializeOwned>(
info_client: &hyperliquid_rust_sdk::InfoClient,
kind: &str,
address: ethers::types::H160,
dex: Option<&str>,
) -> Result<T, UnindexedClientError> {
let mut body = serde_json::json!({"type": kind, "user": format!("{address:?}")});
if let Some(dex) = dex {
body["dex"] = dex.into();
}
info(info_client, kind, body.to_string()).await
}
pub(super) async fn info<T: DeserializeOwned>(
info_client: &hyperliquid_rust_sdk::InfoClient,
kind: &str,
body: String,
) -> Result<T, UnindexedClientError> {
let raw = info_client
.http_client
.post("/info", body)
.await
.map_err(map_sdk_error)?;
serde_json::from_str(&raw).map_err(|e| {
UnindexedClientError::Internal(format!("Hyperliquid {kind} response did not parse: {e}"))
})
}
pub(super) fn spot_balances<'a>(
balances: impl IntoIterator<Item = &'a hyperliquid_rust_sdk::UserTokenBalance>,
now: DateTime<Utc>,
) -> impl Iterator<Item = AssetBalance<AssetNameExchange>> {
balances.into_iter().map(move |balance| {
let total = parse_decimal(&balance.total, "total").unwrap_or(Decimal::ZERO);
let hold = parse_decimal(&balance.hold, "hold").unwrap_or(Decimal::ZERO);
let free = (total - hold).max(Decimal::ZERO);
AssetBalance::new(
AssetNameExchange::from(balance.coin.as_str()),
Balance::new(total, free),
now,
)
})
}
pub(super) fn open_order_to_order(
row: &OpenOrder,
exchange: ExchangeId,
instrument: InstrumentNameExchange,
) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
let parsed = (|| {
let cid = record_cid(row.cloid.as_deref(), row.oid)?;
let side = parse_side(&row.side)?;
let price = parse_decimal(&row.limit_px, "limitPx")?;
let remaining = parse_decimal(&row.sz, "sz")?;
let quantity = match &row.orig_sz {
Some(orig_sz) => parse_decimal(orig_sz, "origSz")?,
None => remaining,
};
let time_exchange = millis_to_datetime(row.timestamp)?;
Some((cid, side, price, quantity, remaining, time_exchange))
})();
let Some((cid, side, price, quantity, remaining, time_exchange)) = parsed else {
warn!(
%exchange,
%instrument,
oid = row.oid,
order = ?row,
"Hyperliquid open order did not convert - leaving it out, so this instrument's order \
list is not complete"
);
return None;
};
Some(Order {
key: OrderKey {
exchange,
instrument,
strategy: StrategyId::unknown(),
cid,
},
side,
price: Some(price),
quantity,
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state: Open {
id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", row.oid))),
time_exchange,
filled_quantity: (quantity - remaining).max(Decimal::ZERO),
},
})
}
pub(super) struct InstrumentFilter<'a>(Option<FnvHashSet<&'a InstrumentNameExchange>>);
impl<'a> InstrumentFilter<'a> {
pub(super) fn new(instruments: &'a [InstrumentNameExchange]) -> Self {
Self((!instruments.is_empty()).then(|| instruments.iter().collect()))
}
pub(super) fn matches(&self, instrument: &InstrumentNameExchange) -> bool {
self.0.as_ref().is_none_or(|set| set.contains(instrument))
}
}
type UnindexedInstrumentAccountSnapshot =
InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
pub(super) struct OpenOrderListing(
HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
);
impl OpenOrderListing {
pub(super) fn new(
rows: &[OpenOrder],
exchange: ExchangeId,
requested: &[InstrumentNameExchange],
to_instrument: impl Fn(&str) -> Option<InstrumentNameExchange>,
) -> Self {
fn entry<'a>(
listing: &'a mut HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
instrument: &InstrumentNameExchange,
) -> &'a mut UnindexedInstrumentAccountSnapshot {
listing
.entry(instrument.clone())
.or_insert_with(|| InstrumentAccountSnapshot {
instrument: instrument.clone(),
orders: Vec::new(),
orders_complete: true,
position: PositionReport::Unreported,
isolated: None,
})
}
let filter = InstrumentFilter::new(requested);
let mut listing = HashMap::with_capacity(requested.len());
for instrument in requested {
entry(&mut listing, instrument);
}
for row in rows {
let Some(instrument) = to_instrument(&row.coin) else {
continue;
};
if !filter.matches(&instrument) {
continue;
}
let order = open_order_to_order(row, exchange, instrument.clone());
let snapshot = entry(&mut listing, &instrument);
match order {
Some(order) => snapshot.orders.push(order.into()),
None => snapshot.orders_complete = false,
}
}
Self(listing)
}
pub(super) fn remove(
&mut self,
instrument: &InstrumentNameExchange,
) -> Option<UnindexedInstrumentAccountSnapshot> {
self.0.remove(instrument)
}
pub(super) fn into_snapshots(self) -> impl Iterator<Item = UnindexedInstrumentAccountSnapshot> {
self.0.into_values()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OrderStatus {
Open,
Filled,
Cancelled,
Rejected,
}
impl OrderStatus {
fn classify(status: &str) -> Option<Self> {
match status {
"open" | "triggered" => Some(Self::Open),
"filled" => Some(Self::Filled),
"canceled" | "scheduledCancel" => Some(Self::Cancelled),
"rejected" => Some(Self::Rejected),
status if status.ends_with("Canceled") => Some(Self::Cancelled),
status if status.ends_with("Rejected") => Some(Self::Rejected),
_ => None,
}
}
}
pub(super) fn order_update_to_account_event(
update: &hyperliquid_rust_sdk::OrderUpdate,
exchange: ExchangeId,
instrument: InstrumentNameExchange,
) -> Option<UnindexedAccountEvent> {
let snapshot = order_update_to_order(update, exchange, instrument)?;
Some(crate::AccountEvent::new(
exchange,
crate::AccountEventKind::OrderSnapshot(
rustrade_integration::collection::snapshot::Snapshot(snapshot),
),
))
}
pub(super) fn order_update_to_order(
update: &hyperliquid_rust_sdk::OrderUpdate,
exchange: ExchangeId,
instrument: InstrumentNameExchange,
) -> Option<UnindexedOrderSnapshot> {
let order = &update.order;
let Some(status) = OrderStatus::classify(&update.status) else {
warn!(%exchange, status = %update.status, oid = order.oid, "Unknown Hyperliquid order status - ignoring the update");
return None;
};
let cid = record_cid(order.cloid.as_deref(), order.oid)?;
let side = parse_side(&order.side)?;
let price = parse_decimal(&order.limit_px, "order.limitPx")?;
let orig_sz = parse_decimal(&order.orig_sz, "order.origSz")?;
let Some(time_exchange) = millis_to_datetime(update.status_timestamp) else {
warn!(%exchange, oid = order.oid, status_timestamp = update.status_timestamp, "Invalid Hyperliquid order update timestamp - ignoring the update");
return None;
};
let order_id = OrderId(format_smolstr!("{}", order.oid));
let filled_quantity = || {
parse_decimal(&order.sz, "order.sz")
.map(|remaining| (orig_sz - remaining).max(Decimal::ZERO))
};
let state = match status {
OrderStatus::Open => OrderState::active(Open {
id: VenueOrderId::Assigned(order_id),
time_exchange,
filled_quantity: filled_quantity()?,
}),
OrderStatus::Filled => OrderState::fully_filled(Filled::new(
order_id,
time_exchange,
orig_sz,
None, )),
OrderStatus::Cancelled => {
OrderState::inactive(Cancelled::new(order_id, time_exchange, filled_quantity()))
}
OrderStatus::Rejected => {
OrderState::inactive(OrderError::Rejected(order_rejection(update.status.clone())))
}
};
Some(Order {
key: OrderKey {
exchange,
instrument,
strategy: StrategyId::unknown(),
cid,
},
side,
price: Some(price),
quantity: orig_sz,
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state,
})
}
pub fn parse_decimal(value: &str, field: &str) -> Option<Decimal> {
Decimal::from_str(value)
.map_err(|e| warn!(%field, %value, %e, "Failed to parse decimal"))
.ok()
}
pub fn parse_side(side: &str) -> Option<Side> {
match side {
"B" | "b" | "BUY" | "Buy" | "buy" => Some(Side::Buy),
"A" | "a" | "S" | "s" | "SELL" | "Sell" | "sell" => Some(Side::Sell),
_ => {
warn!(%side, "Unknown side string");
None
}
}
}
pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
.single()
}
pub fn map_tif(tif: &TimeInForce) -> &'static str {
match tif {
TimeInForce::GoodUntilCancelled { post_only: true } => "Alo",
TimeInForce::GoodUntilCancelled { post_only: false } => "Gtc",
TimeInForce::ImmediateOrCancel => "Ioc",
TimeInForce::FillOrKill => "Ioc", TimeInForce::GoodUntilEndOfDay => "Gtc", TimeInForce::GoodTillDate { .. } | TimeInForce::AtOpen | TimeInForce::AtClose => {
warn!(time_in_force = ?tif, "Hyperliquid does not support this TimeInForce; coercing to Gtc");
"Gtc"
}
}
}
pub(super) const CLOID_REQUIRED: &str = "Hyperliquid requires the client order id to be a UUID in \
canonical form, as ClientOrderId::uuid() makes: the venue reports each order under that id, \
and an order under any other id could not be matched to its own updates";
pub fn cid_to_cloid(cid: &ClientOrderId) -> Option<Uuid> {
let uuid = Uuid::try_parse(cid.0.as_str()).ok()?;
let mut canonical = Uuid::encode_buffer();
(uuid.hyphenated().encode_lower(&mut canonical) == cid.0.as_str()).then_some(uuid)
}
pub fn cloid_to_cid(cloid: &str) -> Option<ClientOrderId> {
let hex = cloid.strip_prefix("0x")?;
if hex.len() != 32 {
return None;
}
let uuid = Uuid::try_parse(hex).ok()?;
Some(ClientOrderId::new(format_smolstr!("{}", uuid.hyphenated())))
}
pub(super) fn cancel_outcome(response: ExchangeResponseStatus) -> Result<(), String> {
let response = match response {
ExchangeResponseStatus::Ok(response) => response,
ExchangeResponseStatus::Err(reason) => return Err(reason),
};
match response
.data
.and_then(|data| data.statuses.into_iter().next())
{
Some(ExchangeDataStatus::Success) => Ok(()),
Some(ExchangeDataStatus::Error(reason)) => Err(reason),
Some(status) => Err(format!("cancel answered with status {status:?}")),
None => Err("cancel answered without a status for the order".to_owned()),
}
}
pub(super) fn record_cid(cloid: Option<&str>, oid: u64) -> Option<ClientOrderId> {
match cloid {
None => Some(ClientOrderId::new(format_smolstr!("{oid}"))),
Some(cloid) => {
let cid = cloid_to_cid(cloid);
if cid.is_none() {
warn!(%cloid, oid, "Hyperliquid order carries a cloid that is not 0x and 32 hex digits");
}
cid
}
}
}
fn is_unknown_coin(coin: &str) -> bool {
!matches!(CoinKind::of(coin), CoinKind::Perp | CoinKind::Spot)
}
pub(super) fn warn_unknown_coins<'a>(coins: impl IntoIterator<Item = &'a str>) {
let unknown: BTreeSet<&str> = coins
.into_iter()
.filter(|coin| is_unknown_coin(coin))
.collect();
if !unknown.is_empty() {
warn!(
?unknown,
"Hyperliquid coins name markets of an unrecognised kind; leaving them out"
);
}
}
#[derive(Debug, Default)]
pub(super) struct UnknownCoins(HashSet<String>);
impl UnknownCoins {
pub(super) fn warn_once(&mut self, coin: &str) {
if is_unknown_coin(coin) && !self.0.contains(coin) {
warn!(
%coin,
"Hyperliquid coin names a market of an unrecognised kind; leaving it out \
(logged once per stream)"
);
self.0.insert(coin.to_owned());
}
}
}
pub fn spot_pair_to_instrument(pair: &SpotPair) -> InstrumentNameExchange {
InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", pair.base(), pair.quote()))
}
pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
let (base, quote) = spot_base_quote(instrument)?;
Some(format!("{base}/{quote}"))
}
pub(super) fn spot_base_quote(instrument: &InstrumentNameExchange) -> Option<(&str, &str)> {
instrument.as_ref().strip_suffix("-SPOT")?.split_once('-')
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn fills_at(times: impl IntoIterator<Item = u64>) -> Vec<UserFill> {
times
.into_iter()
.map(|time| {
serde_json::from_value(serde_json::json!({
"coin": "BTC", "side": "B", "px": "1", "sz": "1", "time": time,
"oid": 1, "fee": "0", "tid": time
}))
.unwrap()
})
.collect()
}
#[test]
fn held_since_is_the_oldest_fill_only_when_the_retention_limit_is_reached() {
let full = fills_at((0..FILLS_RETAINED as u64).map(|i| 5_000 + i));
assert_eq!(
held_since(&full, 1_000),
Some(Utc.timestamp_millis_opt(5_000).unwrap())
);
let short = fills_at((0..FILLS_RETAINED as u64 - 1).map(|i| 5_000 + i));
assert_eq!(held_since(&short, 1_000), None);
}
#[test]
fn held_since_is_none_when_the_oldest_fill_is_not_after_the_span_start() {
let full = fills_at((0..FILLS_RETAINED as u64).map(|i| 5_000 + i));
assert_eq!(held_since(&full, 5_000), None);
assert_eq!(held_since(&full, 9_000), None);
}
#[test]
fn an_instrument_filter_keeps_what_it_was_given_or_everything_when_given_nothing() {
let (btc, eth) = (
InstrumentNameExchange::new("BTC"),
InstrumentNameExchange::new("ETH"),
);
let all = InstrumentFilter::new(&[]);
assert!(all.matches(&btc) && all.matches(ð));
let given = [btc.clone()];
let one = InstrumentFilter::new(&given);
assert!(one.matches(&btc));
assert!(!one.matches(ð));
}
#[test]
fn test_parse_decimal_valid() {
assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
}
#[test]
fn test_parse_decimal_invalid() {
assert_eq!(parse_decimal("", "test"), None);
assert_eq!(parse_decimal("abc", "test"), None);
assert_eq!(parse_decimal("12.34.56", "test"), None);
}
#[test]
fn test_parse_side() {
assert_eq!(parse_side("B"), Some(Side::Buy));
assert_eq!(parse_side("BUY"), Some(Side::Buy));
assert_eq!(parse_side("buy"), Some(Side::Buy));
assert_eq!(parse_side("A"), Some(Side::Sell));
assert_eq!(parse_side("S"), Some(Side::Sell));
assert_eq!(parse_side("SELL"), Some(Side::Sell));
assert_eq!(parse_side("sell"), Some(Side::Sell));
assert_eq!(parse_side("X"), None);
assert_eq!(parse_side(""), None);
}
#[test]
fn test_spot_pair_to_instrument() {
let pairs = super::super::spot_coins::test_spot_pairs();
let instrument = |coin| spot_pair_to_instrument(pairs.get(coin).unwrap());
assert_eq!(instrument("PURR/USDC").as_ref(), "PURR-USDC-SPOT");
assert_eq!(instrument("@107").as_ref(), "HYPE-USDC-SPOT");
assert_eq!(instrument("@207").as_ref(), "HYPE-USDT0-SPOT");
}
#[test]
fn unknown_coins_are_recorded_once_and_known_ones_never() {
let mut unknown = UnknownCoins::default();
for coin in ["#12", "#12", "BTC", "@107", "PURR/USDC", "+3"] {
unknown.warn_once(coin);
}
assert_eq!(
unknown.0,
HashSet::from(["#12".to_owned(), "+3".to_owned()])
);
}
fn cancel_answer(statuses: serde_json::Value) -> ExchangeResponseStatus {
serde_json::from_value(info_tests::cancel_answer(statuses)).unwrap()
}
#[test]
fn only_a_success_status_is_a_cancel() {
assert_eq!(
cancel_outcome(cancel_answer(serde_json::json!(["success"]))),
Ok(())
);
}
#[test]
fn an_error_status_under_a_top_level_ok_is_not_a_cancel() {
let answer = cancel_answer(serde_json::json!([
{"error": info_tests::CANCEL_NOT_APPLIED}
]));
assert_eq!(
cancel_outcome(answer),
Err(info_tests::CANCEL_NOT_APPLIED.to_owned())
);
}
#[test]
fn an_answer_without_a_success_status_is_not_a_cancel() {
let no_data: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
"status": "ok",
"response": {"type": "cancel"},
}))
.unwrap();
let answers = [
no_data,
cancel_answer(serde_json::json!([])),
cancel_answer(serde_json::json!([{"resting": {"oid": 1}}])),
];
for answer in answers {
let outcome = cancel_outcome(answer.clone());
assert!(outcome.is_err(), "{answer:?} read as {outcome:?}");
}
}
#[test]
fn a_top_level_error_is_not_a_cancel() {
let answer: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
"status": "err",
"response": "User or API Wallet does not exist.",
}))
.unwrap();
assert_eq!(
cancel_outcome(answer),
Err("User or API Wallet does not exist.".to_owned())
);
}
#[test]
fn test_instrument_to_spot_coin() {
let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
assert_eq!(coin, Some("PURR/USDC".to_string()));
let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
assert_eq!(coin, Some("HYPE/USDC".to_string()));
assert_eq!(
instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USDC-PERP")),
None
);
assert_eq!(
instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
None
);
}
#[test]
fn test_map_tif() {
assert_eq!(
map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
"Gtc"
);
assert_eq!(
map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
"Alo"
);
assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
}
#[test]
fn test_millis_to_datetime() {
let dt = millis_to_datetime(1714100000000).unwrap();
assert_eq!(dt.timestamp_millis(), 1714100000000);
assert!(millis_to_datetime(0).is_some());
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
pub(super) mod info_tests {
use super::*;
use crate::error::ApiError;
use rust_decimal_macros::dec;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const DOCUMENTED_FILL: &str = r#"{
"closedPnl": "0.0",
"coin": "AVAX",
"crossed": false,
"dir": "Open Long",
"hash": "0xa166e3fa63c25663024b03f2e0da011a00307e4017465df020210d3d432e7cb8",
"oid": 90542681,
"px": "18.435",
"side": "B",
"startPosition": "26.86",
"sz": "93.53",
"time": 1681222254710,
"fee": "0.01",
"feeToken": "USDC",
"builderFee": "0.01",
"tid": 118906512037719
}"#;
pub(in crate::client::hyperliquid) async fn info_client_against(
uri: String,
) -> hyperliquid_rust_sdk::InfoClient {
let mut client = hyperliquid_rust_sdk::InfoClient::new(
None,
Some(hyperliquid_rust_sdk::BaseUrl::Localhost),
)
.await
.unwrap();
client.http_client.base_url = uri;
client
}
pub(in crate::client::hyperliquid) fn test_wallet() -> ethers::signers::LocalWallet {
"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
.parse()
.unwrap()
}
pub(in crate::client::hyperliquid) async fn exchange_client_against(
uri: String,
coin_to_asset: HashMap<String, u32>,
) -> hyperliquid_rust_sdk::ExchangeClient {
hyperliquid_rust_sdk::ExchangeClient {
http_client: info_client_against(uri).await.http_client,
wallet: test_wallet(),
meta: hyperliquid_rust_sdk::Meta {
universe: Vec::new(),
},
vault_address: None,
coin_to_asset,
}
}
pub(in crate::client::hyperliquid) fn cancel_answer(
statuses: serde_json::Value,
) -> serde_json::Value {
serde_json::json!({
"status": "ok",
"response": {"type": "cancel", "data": {"statuses": statuses}},
})
}
pub(in crate::client::hyperliquid) const CANCEL_NOT_APPLIED: &str =
"Order was never placed, already canceled, or filled.";
async fn serve(body: String) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/info"))
.respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
.mount(&server)
.await;
let client = info_client_against(server.uri()).await;
(server, client)
}
#[tokio::test]
async fn a_fill_carries_the_tid_and_fee_token_the_sdk_type_drops() {
let (_server, client) = serve(format!("[{DOCUMENTED_FILL}]")).await;
let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
.await
.unwrap();
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].tid, 118_906_512_037_719);
assert_eq!(fills[0].fee_token.as_deref(), Some("USDC"));
assert_eq!(fills[0].oid, 90_542_681);
assert_eq!(fills[0].coin, "AVAX");
}
#[tokio::test]
async fn one_transaction_can_carry_several_fills_and_they_are_told_apart_by_tid() {
let sweep = r#"[
{"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
"hash":"0xsweep","oid":1,"px":"18.40","side":"B","startPosition":"0",
"sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":111},
{"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
"hash":"0xsweep","oid":1,"px":"18.45","side":"B","startPosition":"10",
"sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":222},
{"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
"hash":"0xsweep","oid":1,"px":"18.50","side":"B","startPosition":"20",
"sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":333}
]"#;
let (_server, client) = serve(sweep.to_string()).await;
let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
.await
.unwrap();
let tids: Vec<u64> = fills.iter().map(|f| f.tid).collect();
assert_eq!(tids, vec![111, 222, 333]);
}
#[tokio::test]
async fn a_missing_fee_token_falls_back_rather_than_failing() {
let without = DOCUMENTED_FILL.replace(r#""feeToken": "USDC","#, "");
let (_server, client) = serve(format!("[{without}]")).await;
let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
.await
.unwrap();
assert_eq!(fills.len(), 1);
assert!(fills[0].fee_token.is_none());
}
#[tokio::test]
async fn a_missing_tid_fails_loudly_instead_of_falling_back_to_the_hash() {
let without = DOCUMENTED_FILL.replace(r#""tid": 118906512037719"#, r#""unused": 0"#);
let (_server, client) = serve(format!("[{without}]")).await;
let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
.await
.unwrap_err();
assert!(
matches!(&error, UnindexedClientError::Internal(msg) if msg.contains("userFills")),
"expected a parse failure naming the endpoint, got {error:?}"
);
}
#[tokio::test]
async fn the_request_names_the_address_in_full() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/info"))
.and(wiremock::matchers::body_string_contains(
"0x000000000000000000000000000000000000dead",
))
.and(wiremock::matchers::body_string_contains("userFills"))
.respond_with(ResponseTemplate::new(200).set_body_raw("[]", "application/json"))
.mount(&server)
.await;
let client = info_client_against(server.uri()).await;
let address = "0x000000000000000000000000000000000000dEaD"
.parse::<ethers::types::H160>()
.unwrap();
assert!(
user_fills_by_time(&client, address, 0, u64::MAX)
.await
.unwrap()
.is_empty()
);
}
struct FillsByTime {
fills: Vec<serde_json::Value>,
}
impl wiremock::Respond for FillsByTime {
fn respond(&self, request: &wiremock::Request) -> ResponseTemplate {
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
assert_eq!(body["type"], "userFillsByTime");
let bound = |name: &str| body[name].as_u64().unwrap();
let (start, end) = (bound("startTime"), bound("endTime"));
let page: Vec<_> = self
.fills
.iter()
.filter(|fill| (start..=end).contains(&fill["time"].as_u64().unwrap()))
.take(USER_FILLS_PER_RESPONSE)
.cloned()
.collect();
ResponseTemplate::new(200).set_body_json(page)
}
}
fn fill(tid: u64, time: u64) -> serde_json::Value {
let mut fill: serde_json::Value = serde_json::from_str(DOCUMENTED_FILL).unwrap();
fill["tid"] = tid.into();
fill["time"] = time.into();
fill
}
async fn serve_fills_by_time(
fills: Vec<serde_json::Value>,
) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/info"))
.respond_with(FillsByTime { fills })
.mount(&server)
.await;
let client = info_client_against(server.uri()).await;
(server, client)
}
#[tokio::test]
async fn a_span_with_more_fills_than_one_response_is_read_whole() {
let fills = (0..4_500).map(|tid| fill(tid, 1_000 + tid / 3)).collect();
let (server, client) = serve_fills_by_time(fills).await;
let read = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
.await
.unwrap();
let tids: Vec<u64> = read.iter().map(|fill| fill.tid).collect();
assert_eq!(tids, (0..4_500).collect::<Vec<_>>());
assert_eq!(server.received_requests().await.map(|r| r.len()), Some(3));
let read = user_fills_by_time(&client, ethers::types::H160::zero(), 1_010, 1_019)
.await
.unwrap();
assert_eq!(read.len(), 30);
}
#[tokio::test]
async fn a_read_that_cannot_advance_is_truncated() {
let fills = (0..USER_FILLS_PER_RESPONSE as u64)
.map(|tid| fill(tid, 5))
.collect();
let (_server, client) = serve_fills_by_time(fills).await;
let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
.await
.unwrap_err();
assert!(
matches!(error, UnindexedClientError::Truncated { fills_read: 2_000 }),
"got {error:?}"
);
}
#[test]
fn a_span_is_read_in_whole_milliseconds_and_an_empty_one_not_at_all() {
let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
assert_eq!(
span_millis(
at("1970-01-01T00:00:01.000900Z"),
at("1970-01-01T00:00:02.000900Z")
),
Some((1_000, 2_000))
);
assert_eq!(
span_millis(at("1969-12-31T23:59:59Z"), at("1970-01-01T00:00:01Z")),
Some((0, 1_000))
);
assert_eq!(
span_millis(at("1970-01-01T00:00:02Z"), at("1970-01-01T00:00:01Z")),
None
);
assert_eq!(
span_millis(at("1969-01-01T00:00:00Z"), at("1969-12-31T23:59:59Z")),
None
);
}
const CID: &str = "85b60bff-64d5-49ec-95af-6268d7d1df63";
const CLOID: &str = "0x85b60bff64d549ec95af6268d7d1df63";
#[test]
fn a_user_fill_reads_its_cloid() {
let fill = |cloid: &str| {
serde_json::from_str::<UserFill>(&format!(
r#"{{"coin": "BTC", "side": "B", "px": "1", "sz": "1", "time": 1, "oid": 7,
"fee": "0", "tid": 9{cloid}}}"#
))
.unwrap()
};
let with = fill(&format!(r#", "cloid": "{CLOID}""#));
assert_eq!(with.cloid.as_deref(), Some(CLOID));
assert!(cloid_to_cid(CLOID).is_some());
assert_eq!(fill("").cloid, None);
assert_eq!(fill(r#", "cloid": null"#).cloid, None);
}
#[test]
fn only_the_canonical_uuid_form_can_be_a_cloid() {
let cloid = cid_to_cloid(&ClientOrderId::new(CID)).unwrap();
assert_eq!(cloid.to_string(), CID);
for other in [
"85B60BFF-64D5-49EC-95AF-6268D7D1DF63",
"85b60bff64d549ec95af6268d7d1df63",
"{85b60bff-64d5-49ec-95af-6268d7d1df63}",
"urn:uuid:85b60bff-64d5-49ec-95af-6268d7d1df63",
"example-1714100000000",
"",
] {
assert_eq!(cid_to_cloid(&ClientOrderId::new(other)), None, "{other}");
}
}
#[test]
fn a_generated_uuid_client_id_is_always_a_cloid() {
for _ in 0..32 {
let cid = ClientOrderId::uuid();
assert!(cid_to_cloid(&cid).is_some(), "{cid}");
}
}
#[test]
fn the_reported_cloid_is_the_client_id_the_order_was_placed_under() {
assert_eq!(cloid_to_cid(CLOID), Some(ClientOrderId::new(CID)));
let cid = ClientOrderId::uuid();
let sent = format!("0x{}", cid_to_cloid(&cid).unwrap().simple());
assert_eq!(cloid_to_cid(&sent), Some(cid));
}
#[test]
fn a_cloid_not_in_the_venue_form_names_no_client_id() {
for malformed in [
CID,
"85b60bff64d549ec95af6268d7d1df63",
"0x85b60bff64d549ec95af6268d7d1df",
"0x85b60bff64d549ec95af6268d7d1df6300",
"0x85b60bff-64d5-49ec-95af-6268d7d1df63",
"0xzzb60bff64d549ec95af6268d7d1df63",
"0x",
] {
assert_eq!(cloid_to_cid(malformed), None, "{malformed}");
}
}
#[test]
fn a_record_without_a_cloid_is_reported_under_its_oid() {
assert_eq!(record_cid(None, 42), Some(ClientOrderId::new("42")));
assert_eq!(record_cid(Some(CLOID), 42), Some(ClientOrderId::new(CID)));
assert_eq!(record_cid(Some("0x1234"), 42), None);
}
#[test]
fn every_documented_order_status_is_classified() {
use OrderStatus::*;
for (status, expected) in [
("open", Open),
("triggered", Open),
("filled", Filled),
("canceled", Cancelled),
("marginCanceled", Cancelled),
("vaultWithdrawalCanceled", Cancelled),
("openInterestCapCanceled", Cancelled),
("selfTradeCanceled", Cancelled),
("reduceOnlyCanceled", Cancelled),
("siblingFilledCanceled", Cancelled),
("delistedCanceled", Cancelled),
("liquidatedCanceled", Cancelled),
("scheduledCancel", Cancelled),
("rejected", Rejected),
("tickRejected", Rejected),
("minTradeNtlRejected", Rejected),
("perpMarginRejected", Rejected),
("reduceOnlyRejected", Rejected),
("badAloPxRejected", Rejected),
("iocCancelRejected", Rejected),
("badTriggerPxRejected", Rejected),
("marketOrderNoLiquidityRejected", Rejected),
("positionIncreaseAtOpenInterestCapRejected", Rejected),
("positionFlipAtOpenInterestCapRejected", Rejected),
("tooAggressiveAtOpenInterestCapRejected", Rejected),
("openInterestIncreaseRejected", Rejected),
("insufficientSpotBalanceRejected", Rejected),
("oracleRejected", Rejected),
("perpMaxPositionRejected", Rejected),
] {
assert_eq!(OrderStatus::classify(status), Some(expected), "{status}");
}
assert_eq!(OrderStatus::classify("unknown_status"), None);
}
fn order_update(status: &str, cloid: &str, sz: &str) -> hyperliquid_rust_sdk::OrderUpdate {
serde_json::from_str(&format!(
r#"{{"order": {{"coin": "ETH", "side": "B", "limitPx": "1605.0", "sz": "{sz}",
"oid": 7001, "timestamp": 1714100000000, "origSz": "0.0075", "cloid": {cloid}}},
"status": "{status}", "statusTimestamp": 1714100001000}}"#
))
.unwrap()
}
fn snapshot_of(event: UnindexedAccountEvent) -> UnindexedOrderSnapshot {
match event.kind {
crate::AccountEventKind::OrderSnapshot(snapshot) => snapshot.0,
other => panic!("expected an order snapshot, got {other:?}"),
}
}
fn eth() -> InstrumentNameExchange {
InstrumentNameExchange::from("ETH-USDC-PERP")
}
#[test]
fn an_order_update_names_the_order_by_the_id_it_was_placed_under() {
let update = order_update("open", &format!(r#""{CLOID}""#), "0.005");
let order = snapshot_of(
order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
);
assert_eq!(order.key.cid, ClientOrderId::new(CID));
assert_eq!(order.quantity, dec!(0.0075));
match order.state {
OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) => {
assert_eq!(open.id, VenueOrderId::Assigned(OrderId::new("7001")));
assert_eq!(open.filled_quantity, dec!(0.0025));
}
other => panic!("expected Open, got {other:?}"),
}
}
#[test]
fn an_order_update_without_a_cloid_is_named_by_its_oid() {
let update = order_update("open", "null", "0.0075");
let order = snapshot_of(
order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
);
assert_eq!(order.key.cid, ClientOrderId::new("7001"));
}
#[test]
fn an_order_update_with_a_malformed_cloid_is_dropped() {
let update = order_update("open", r#""0x1234""#, "0.0075");
assert!(
order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).is_none()
);
}
#[test]
fn a_cancellation_for_any_reason_ends_the_order() {
let update = order_update("selfTradeCanceled", &format!(r#""{CLOID}""#), "0.005");
let order = snapshot_of(
order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
);
match order.state {
OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
assert_eq!(cancelled.filled_quantity, Some(dec!(0.0025)));
}
other => panic!("expected Cancelled, got {other:?}"),
}
}
#[test]
fn a_cancellation_with_an_unparseable_size_ends_the_order_with_its_fill_unknown() {
let update = order_update("canceled", &format!(r#""{CLOID}""#), "not a number");
let order = snapshot_of(
order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
);
match order.state {
OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
assert_eq!(cancelled.filled_quantity, None);
}
other => panic!("expected Cancelled, got {other:?}"),
}
}
#[test]
fn a_status_saying_funds_ran_short_ends_the_order_as_balance_insufficient() {
for status in ["perpMarginRejected", "insufficientSpotBalanceRejected"] {
let update = order_update(status, &format!(r#""{CLOID}""#), "0.0075");
let order = snapshot_of(
order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
);
match order.state {
OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
OrderError::Rejected(ApiError::BalanceInsufficient(None, reason)),
)) => assert_eq!(reason, status),
other => panic!("{status}: expected BalanceInsufficient, got {other:?}"),
}
}
}
#[test]
fn a_rejection_ends_the_order_and_names_the_reason() {
let update = order_update("badAloPxRejected", &format!(r#""{CLOID}""#), "0.0075");
let order = snapshot_of(
order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
);
match order.state {
OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
OrderError::Rejected(ApiError::OrderRejected(reason)),
)) => assert_eq!(reason, "badAloPxRejected"),
other => panic!("expected a rejection, got {other:?}"),
}
}
const OPEN_ORDERS: &str = r#"[
{"coin":"ETH","side":"B","limitPx":"1605.0","sz":"0.005","oid":7001,
"timestamp":1714100000000,"origSz":"0.0075","cloid":"0x85b60bff64d549ec95af6268d7d1df63"},
{"coin":"ETH","side":"A","limitPx":"1700.0","sz":"0.01","oid":7002,
"timestamp":1714100000000,"origSz":"0.01"},
{"coin":"BTC","side":"A","limitPx":"50000.0","sz":"0.001","oid":7003,
"timestamp":1714100000000}
]"#;
#[tokio::test]
async fn open_orders_carry_the_cloid_and_original_size_the_sdk_type_drops() {
let (_server, client) = serve(OPEN_ORDERS.to_string()).await;
let rows = open_orders(&client, ethers::types::H160::zero())
.await
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].cloid.as_deref(), Some(CLOID));
assert_eq!(rows[0].orig_sz.as_deref(), Some("0.0075"));
assert_eq!(rows[1].cloid, None);
assert_eq!(rows[2].orig_sz, None);
}
fn rows() -> Vec<OpenOrder> {
serde_json::from_str(OPEN_ORDERS).unwrap()
}
fn perp(coin: &str) -> Option<InstrumentNameExchange> {
Some(InstrumentNameExchange::from(format!("{coin}-USDC-PERP")))
}
#[test]
fn an_open_order_is_listed_under_its_client_id_with_its_fill_so_far() {
let order = open_order_to_order(&rows()[0], ExchangeId::HyperliquidPerp, eth()).unwrap();
assert_eq!(order.key.cid, ClientOrderId::new(CID));
assert_eq!(order.quantity, dec!(0.0075));
assert_eq!(order.state.filled_quantity, dec!(0.0025));
assert_eq!(order.state.id, VenueOrderId::Assigned(OrderId::new("7001")));
}
#[test]
fn an_open_order_without_its_original_size_is_its_remainder_with_nothing_filled() {
let row = &rows()[2];
let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
let order = open_order_to_order(row, ExchangeId::HyperliquidPerp, btc).unwrap();
assert_eq!(order.key.cid, ClientOrderId::new("7003"));
assert_eq!(order.quantity, dec!(0.001));
assert_eq!(order.state.filled_quantity, Decimal::ZERO);
}
#[test]
fn every_requested_instrument_is_listed_complete_even_with_nothing_open() {
let sol = InstrumentNameExchange::from("SOL-USDC-PERP");
let mut listing = OpenOrderListing::new(
&rows(),
ExchangeId::HyperliquidPerp,
&[eth(), sol.clone()],
perp,
);
let eth_entry = listing.remove(ð()).unwrap();
assert!(eth_entry.orders_complete);
assert_eq!(eth_entry.orders.len(), 2);
let sol_entry = listing.remove(&sol).unwrap();
assert!(sol_entry.orders_complete);
assert!(sol_entry.orders.is_empty());
assert_eq!(listing.into_snapshots().count(), 0);
}
#[test]
fn an_unfiltered_listing_covers_every_instrument_with_open_orders() {
let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidPerp, &[], perp);
let mut instruments = listing
.into_snapshots()
.map(|entry| entry.instrument.to_string())
.collect::<Vec<_>>();
instruments.sort();
assert_eq!(instruments, ["BTC-USDC-PERP", "ETH-USDC-PERP"]);
}
#[test]
fn an_order_that_does_not_convert_leaves_only_its_instrument_incomplete() {
let mut rows = rows();
rows[1].side = "?".to_string();
let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
let mut listing = OpenOrderListing::new(
&rows,
ExchangeId::HyperliquidPerp,
&[eth(), btc.clone()],
perp,
);
let eth_entry = listing.remove(ð()).unwrap();
assert!(!eth_entry.orders_complete);
assert_eq!(eth_entry.orders.len(), 1);
assert!(listing.remove(&btc).unwrap().orders_complete);
}
#[test]
fn an_order_with_a_malformed_cloid_leaves_its_instrument_incomplete() {
let mut rows = rows();
rows[0].cloid = Some("0x1234".to_string());
let mut listing = OpenOrderListing::new(&rows, ExchangeId::HyperliquidPerp, &[eth()], perp);
let eth_entry = listing.remove(ð()).unwrap();
assert!(!eth_entry.orders_complete);
assert_eq!(eth_entry.orders.len(), 1);
}
#[test]
fn coins_the_client_does_not_trade_are_left_out() {
let pairs = super::super::spot_coins::test_spot_pairs();
let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidSpot, &[], |coin| {
pairs.get(coin).map(spot_pair_to_instrument)
});
assert_eq!(listing.into_snapshots().count(), 0);
}
}