use crate::client::hyperliquid::error::map_sdk_error;
use crate::error::UnindexedClientError;
use crate::order::{TimeInForce, id::ClientOrderId};
use chrono::{DateTime, TimeZone, Utc};
use futures::Stream;
use rust_decimal::Decimal;
use rustrade_instrument::{Side, instrument::name::InstrumentNameExchange};
use smol_str::format_smolstr;
use std::pin::Pin;
use std::str::FromStr;
use std::task::{Context, Poll};
use tokio_util::sync::CancellationToken;
use tracing::{debug, 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 async fn user_fills(
info_client: &hyperliquid_rust_sdk::InfoClient,
address: ethers::types::H160,
) -> Result<Vec<UserFill>, UnindexedClientError> {
let body = format!(r#"{{"type":"userFills","user":"{address:?}"}}"#);
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 userFills response did not parse: {e}"))
})
}
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 round_to_5_sig_figs(value: Decimal) -> f64 {
use rust_decimal::prelude::ToPrimitive;
if value.is_zero() {
return 0.0;
}
let abs_f = value.abs().to_f64().unwrap_or(0.0);
if abs_f == 0.0 {
return 0.0;
}
#[allow(clippy::cast_possible_truncation)]
let magnitude = abs_f.log10().floor().clamp(-30.0, 30.0) as i32;
let rounded = if magnitude >= 4 {
#[allow(clippy::cast_sign_loss)]
let factor = Decimal::from(10i64.pow((magnitude - 4) as u32));
(value / factor).round() * factor
} else {
#[allow(clippy::cast_sign_loss)]
let dp = (4 - magnitude) as u32;
value.round_dp(dp)
};
rounded.to_f64().unwrap_or(0.0)
}
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 fn cid_to_cloid(cid: &ClientOrderId) -> Option<Uuid> {
match Uuid::parse_str(cid.0.as_str()) {
Ok(uuid) => Some(uuid),
Err(_) => {
debug!(cid = %cid.0, "CID is not a valid UUID, cloid will be None");
None
}
}
}
pub fn perp_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
InstrumentNameExchange::from(format_smolstr!("{}-USD-PERP", coin))
}
pub fn instrument_to_perp_coin(instrument: &InstrumentNameExchange) -> String {
let s = instrument.as_ref();
match s.split_once('-') {
Some((coin, _)) => coin.to_string(),
None => s.to_string(),
}
}
pub fn spot_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
match coin.split_once('/') {
Some((base, quote)) => {
InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", base, quote))
}
None => {
debug_assert!(
false,
"spot_coin_to_instrument called with non-spot coin: {coin}"
);
InstrumentNameExchange::from(format_smolstr!("{}-SPOT", coin))
}
}
}
pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
let s = instrument.as_ref();
let without_suffix = s.strip_suffix("-SPOT")?;
let (base, quote) = without_suffix.split_once('-')?;
Some(format!("{}/{}", base, quote))
}
pub fn is_spot_coin(coin: &str) -> bool {
coin.contains('/')
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[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_perp_coin_to_instrument() {
let inst = perp_coin_to_instrument("BTC");
assert_eq!(inst.as_ref(), "BTC-USD-PERP");
let inst = perp_coin_to_instrument("ETH");
assert_eq!(inst.as_ref(), "ETH-USD-PERP");
}
#[test]
fn test_instrument_to_perp_coin() {
let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("BTC-USD-PERP"));
assert_eq!(coin, "BTC");
let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("ETH-USD-PERP"));
assert_eq!(coin, "ETH");
let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("SOL"));
assert_eq!(coin, "SOL");
}
#[test]
fn test_spot_coin_to_instrument() {
let inst = spot_coin_to_instrument("PURR/USDC");
assert_eq!(inst.as_ref(), "PURR-USDC-SPOT");
let inst = spot_coin_to_instrument("HYPE/USDC");
assert_eq!(inst.as_ref(), "HYPE-USDC-SPOT");
}
#[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-USD-PERP")),
None
);
assert_eq!(
instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
None
);
}
#[test]
fn test_is_spot_coin() {
assert!(is_spot_coin("PURR/USDC"));
assert!(is_spot_coin("HYPE/USDC"));
assert!(!is_spot_coin("BTC"));
assert!(!is_spot_coin("ETH"));
}
#[test]
fn test_round_to_5_sig_figs() {
assert_eq!(round_to_5_sig_figs(dec!(0)), 0.0);
assert_eq!(round_to_5_sig_figs(dec!(12345)), 12345.0);
assert_eq!(round_to_5_sig_figs(dec!(123456)), 123460.0);
assert_eq!(round_to_5_sig_figs(dec!(0.00012345)), 0.00012345);
assert_eq!(round_to_5_sig_figs(dec!(0.000123456)), 0.00012346);
assert_eq!(round_to_5_sig_figs(dec!(1.23456789)), 1.2346);
}
#[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)]
mod user_fills_tests {
use super::*;
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
}"#;
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
}
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(&client, ethers::types::H160::zero())
.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(&client, ethers::types::H160::zero())
.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(&client, ethers::types::H160::zero())
.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(&client, ethers::types::H160::zero())
.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(&client, address).await.unwrap().is_empty());
}
}