use std::{
collections::HashMap,
num::NonZeroU32,
sync::{Arc, LazyLock, Mutex},
time::Duration,
};
use ahash::AHashMap;
use anyhow::Context;
use nautilus_common::cache::InstrumentLookupError;
use nautilus_core::{
AtomicMap, MUTEX_POISONED, UUID4, UnixNanos,
consts::NAUTILUS_USER_AGENT,
time::{AtomicTime, get_atomic_clock_realtime},
};
use nautilus_model::{
data::{Bar, BarType},
enums::{
AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
TriggerType,
},
events::AccountState,
identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
instruments::{CurrencyPair, Instrument, InstrumentAny},
orders::{Order, OrderAny},
reports::{FillReport, OrderStatusReport, PositionStatusReport},
types::{AccountBalance, Currency, Price, Quantity},
};
use nautilus_network::{
http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
ratelimiter::quota::Quota,
};
use rust_decimal::Decimal;
use serde_json::Value;
use ustr::Ustr;
use crate::{
account::resolve_execution_account_address,
common::{
consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url},
credential::{Secrets, VaultAddress, credential_env_vars},
enums::{
HyperliquidBarInterval, HyperliquidEnvironment,
HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
},
parse::{
bar_type_to_interval, cache_alias_for_symbol, clamp_price_to_precision,
derive_limit_from_trigger, determine_order_list_grouping, extract_inner_error,
normalize_price, order_to_hyperliquid_request_with_asset_and_cloid,
parse_combined_account_balances_and_margins, parse_spot_account_balances,
round_to_sig_figs, time_in_force_to_hyperliquid_tif,
},
},
data::candle_to_bar,
http::{
error::{Error, Result},
models::{
ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
HyperliquidExchangeResponse, HyperliquidExecAction, HyperliquidExecBuilderFee,
HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecMergeOutcomeParams,
HyperliquidExecMergeQuestionParams, HyperliquidExecModifyOrderRequest,
HyperliquidExecNegateOutcomeParams, HyperliquidExecOrderKind,
HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
HyperliquidExecPlaceOrderRequest, HyperliquidExecSplitOutcomeParams,
HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams,
HyperliquidExecUserOutcomeOp, HyperliquidFills, HyperliquidFundingHistoryEntry,
HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus, HyperliquidRecentTrade,
OutcomeMeta, PerpDex, PerpMeta, PerpMetaAndCtxs, RESPONSE_STATUS_OK,
SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
},
parse::{
HyperliquidInstrumentDef, instruments_from_defs_owned, parse_fill_report,
parse_order_status_report_from_basic, parse_outcome_instruments,
parse_perp_instruments_with_settlement, parse_position_status_report,
parse_spot_instruments, parse_spot_position_status_report,
resolve_perp_settlement_currency,
},
query::{ExchangeAction, InfoRequest},
rate_limits::{
RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
exec_action_weight, info_base_weight, info_extra_weight,
},
},
signing::{
HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
},
websocket::messages::WsBasicOrderData,
};
pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
#[derive(Debug, Clone)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(
module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
from_py_object
)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
)]
pub struct HyperliquidRawHttpClient {
client: HttpClient,
environment: HyperliquidEnvironment,
base_info: String,
base_exchange: String,
signer: Option<HyperliquidEip712Signer>,
nonce_manager: Option<Arc<NonceManager>>,
vault_address: Option<VaultAddress>,
rest_limiter: Arc<WeightedLimiter>,
rate_limit_backoff_base: Duration,
rate_limit_backoff_cap: Duration,
rate_limit_max_attempts_info: u32,
}
impl HyperliquidRawHttpClient {
pub fn new(
environment: HyperliquidEnvironment,
timeout_secs: u64,
proxy_url: Option<String>,
) -> std::result::Result<Self, HttpClientError> {
Ok(Self {
client: HttpClient::new(
Self::default_headers(),
vec![],
vec![],
Some(*HYPERLIQUID_REST_QUOTA),
Some(timeout_secs),
proxy_url,
)?,
environment,
base_info: info_url(environment).to_string(),
base_exchange: exchange_url(environment).to_string(),
signer: None,
nonce_manager: None,
vault_address: None,
rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
rate_limit_backoff_base: Duration::from_millis(125),
rate_limit_backoff_cap: Duration::from_secs(5),
rate_limit_max_attempts_info: 3,
})
}
pub fn with_credentials(
secrets: &Secrets,
timeout_secs: u64,
proxy_url: Option<String>,
) -> std::result::Result<Self, HttpClientError> {
let signer = HyperliquidEip712Signer::new(&secrets.private_key)
.map_err(|e| HttpClientError::from(e.to_string()))?;
let nonce_manager = Arc::new(NonceManager::new());
Ok(Self {
client: HttpClient::new(
Self::default_headers(),
vec![],
vec![],
Some(*HYPERLIQUID_REST_QUOTA),
Some(timeout_secs),
proxy_url,
)?,
environment: secrets.environment,
base_info: info_url(secrets.environment).to_string(),
base_exchange: exchange_url(secrets.environment).to_string(),
signer: Some(signer),
nonce_manager: Some(nonce_manager),
vault_address: secrets.vault_address,
rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
rate_limit_backoff_base: Duration::from_millis(125),
rate_limit_backoff_cap: Duration::from_secs(5),
rate_limit_max_attempts_info: 3,
})
}
pub fn set_base_info_url(&mut self, url: String) {
self.base_info = url;
}
pub fn set_base_exchange_url(&mut self, url: String) {
self.base_exchange = url;
}
pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
let secrets = Secrets::from_env(environment)
.map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
Self::with_credentials(&secrets, 60, None)
.map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
}
pub fn from_credentials(
private_key: &str,
vault_address: Option<&str>,
environment: HyperliquidEnvironment,
timeout_secs: u64,
proxy_url: Option<String>,
) -> Result<Self> {
let secrets = Secrets::from_private_key(private_key, vault_address, environment)
.map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
Self::with_credentials(&secrets, timeout_secs, proxy_url)
.map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
}
#[must_use]
pub fn with_rate_limits(mut self) -> Self {
self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
self.rate_limit_backoff_base = Duration::from_millis(125);
self.rate_limit_backoff_cap = Duration::from_secs(5);
self.rate_limit_max_attempts_info = 3;
self
}
#[must_use]
pub fn environment(&self) -> HyperliquidEnvironment {
self.environment
}
#[must_use]
pub fn is_testnet(&self) -> bool {
self.environment == HyperliquidEnvironment::Testnet
}
pub fn get_user_address(&self) -> Result<String> {
self.signer
.as_ref()
.ok_or_else(|| Error::auth("No signer configured"))?
.address()
}
#[must_use]
pub fn has_vault_address(&self) -> bool {
self.vault_address.is_some()
}
pub fn get_account_address(&self) -> Result<String> {
if let Some(vault) = &self.vault_address {
Ok(vault.to_hex())
} else {
self.get_user_address()
}
}
fn default_headers() -> HashMap<String, String> {
HashMap::from([
(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
("Content-Type".to_string(), "application/json".to_string()),
])
}
fn signer_id(&self) -> SignerId {
SignerId("hyperliquid:default".into())
}
fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
let retry_after = headers.get("retry-after")?;
retry_after.parse::<u64>().ok().map(|s| s * 1000) }
pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
let request = InfoRequest::meta();
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
let request = InfoRequest::spot_meta();
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
let request = InfoRequest::meta_and_asset_ctxs();
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
let request = InfoRequest::spot_meta_and_asset_ctxs();
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
let request = InfoRequest::outcome_meta();
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
let request = InfoRequest::meta();
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
let request = InfoRequest::all_perp_metas();
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub(crate) async fn load_perp_dexs(&self) -> Result<Vec<Option<PerpDex>>> {
let request = InfoRequest::perp_dexs();
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
let request = InfoRequest::l2_book(coin);
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
let request = InfoRequest::recent_trades(coin);
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
let request = InfoRequest::user_fills(user);
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
let request = InfoRequest::order_status(user, oid);
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
let request = InfoRequest::open_orders(user);
self.send_info_request(&request).await
}
pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
let request = InfoRequest::frontend_open_orders(user);
self.send_info_request(&request).await
}
pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
let request = InfoRequest::clearinghouse_state(user);
self.send_info_request(&request).await
}
pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
let request = InfoRequest::spot_clearinghouse_state(user);
self.send_info_request(&request).await
}
pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
let request = InfoRequest::user_fees(user);
self.send_info_request(&request).await
}
pub async fn info_candle_snapshot(
&self,
coin: &str,
interval: HyperliquidBarInterval,
start_time: u64,
end_time: u64,
) -> Result<HyperliquidCandleSnapshot> {
let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
let response = self.send_info_request(&request).await?;
log::trace!(
"Candle snapshot raw response (len={}): {:?}",
response.as_array().map_or(0, |a| a.len()),
response
);
serde_json::from_value(response).map_err(Error::Serde)
}
pub async fn info_funding_history(
&self,
coin: &str,
start_time: u64,
end_time: Option<u64>,
) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
let request = InfoRequest::funding_history(coin, start_time, end_time);
let response = self.send_info_request(&request).await?;
serde_json::from_value(response).map_err(Error::Serde)
}
pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
self.send_info_request(request).await
}
async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
let base_w = info_base_weight(request);
self.rest_limiter.acquire(base_w).await;
let mut attempt = 0u32;
loop {
let response = self.http_roundtrip_info(request).await?;
if response.status.is_success() {
let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
let extra = info_extra_weight(request, &val);
if extra > 0 {
self.rest_limiter.debit_extra(extra).await;
log::debug!(
"Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
);
}
return Ok(val);
}
if response.status.as_u16() == 429 {
if attempt >= self.rate_limit_max_attempts_info {
let ra = self.parse_retry_after_simple(&response.headers);
return Err(Error::rate_limit("info", base_w, ra));
}
let delay = self
.parse_retry_after_simple(&response.headers)
.map_or_else(
|| {
backoff_full_jitter(
attempt,
self.rate_limit_backoff_base,
self.rate_limit_backoff_cap,
)
},
Duration::from_millis,
);
log::warn!(
"429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
delay.as_millis()
);
attempt += 1;
tokio::time::sleep(delay).await;
self.rest_limiter.acquire(1).await;
continue;
}
if (response.status.is_server_error() || response.status.as_u16() == 408)
&& attempt < self.rate_limit_max_attempts_info
{
let delay = backoff_full_jitter(
attempt,
self.rate_limit_backoff_base,
self.rate_limit_backoff_cap,
);
log::warn!(
"Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
response.status.as_u16(),
delay.as_millis()
);
attempt += 1;
tokio::time::sleep(delay).await;
continue;
}
let error_body = String::from_utf8_lossy(&response.body);
return Err(Error::http(
response.status.as_u16(),
error_body.to_string(),
));
}
}
async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
let url = &self.base_info;
let body = serde_json::to_value(request).map_err(Error::Serde)?;
let body_bytes = serde_json::to_string(&body)
.map_err(Error::Serde)?
.into_bytes();
self.client
.request(
Method::POST,
url.clone(),
None,
None,
Some(body_bytes),
None,
None,
)
.await
.map_err(Error::from_http_client)
}
pub async fn post_action(
&self,
action: &ExchangeAction,
) -> Result<HyperliquidExchangeResponse> {
let w = exchange_weight(action);
self.rest_limiter.acquire(w).await;
let signer = self
.signer
.as_ref()
.ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
let nonce_manager = self
.nonce_manager
.as_ref()
.ok_or_else(|| Error::auth("nonce manager missing"))?;
let signer_id = self.signer_id();
let time_nonce = nonce_manager.next(signer_id)?;
let action_bytes = rmp_serde::to_vec_named(action)
.context("serialize action with MessagePack")
.map_err(|e| Error::bad_request(e.to_string()))?;
let sign_request = SignRequest {
action: None,
action_bytes: Some(action_bytes),
time_nonce,
action_type: HyperliquidActionType::L1,
is_testnet: self.is_testnet(),
vault_address: self.vault_address,
expires_after: None,
};
let sig = signer.sign(&sign_request)?.signature;
let nonce_u64 = time_nonce.as_millis() as u64;
let request = if let Some(vault) = self.vault_address {
HyperliquidExchangeRequest::with_vault(
action.clone(),
nonce_u64,
sig,
vault.to_string(),
)
} else {
HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
};
let response = self.http_roundtrip_exchange(&request).await?;
if response.status.is_success() {
let parsed_response: HyperliquidExchangeResponse =
serde_json::from_slice(&response.body).map_err(Error::Serde)?;
match &parsed_response {
HyperliquidExchangeResponse::Status {
status,
response: response_data,
} if status == "err" => {
let error_msg = response_data
.as_str()
.map_or_else(|| response_data.to_string(), |s| s.to_string());
log::error!("Hyperliquid API returned error: {error_msg}");
Err(Error::bad_request(format!("API error: {error_msg}")))
}
HyperliquidExchangeResponse::Error { error } => {
log::error!("Hyperliquid API returned error: {error}");
Err(Error::bad_request(format!("API error: {error}")))
}
_ => Ok(parsed_response),
}
} else if response.status.as_u16() == 429 {
let ra = self.parse_retry_after_simple(&response.headers);
Err(Error::rate_limit("exchange", w, ra))
} else {
let error_body = String::from_utf8_lossy(&response.body);
log::error!(
"Exchange API error (status {}): {}",
response.status.as_u16(),
error_body
);
Err(Error::http(
response.status.as_u16(),
error_body.to_string(),
))
}
}
pub fn sign_action_exec_request(
&self,
action: &HyperliquidExecAction,
expires_after: Option<u64>,
) -> Result<HyperliquidExchangeRequest<HyperliquidExecAction>> {
let signer = self
.signer
.as_ref()
.ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
let nonce_manager = self
.nonce_manager
.as_ref()
.ok_or_else(|| Error::auth("nonce manager missing"))?;
let signer_id = self.signer_id();
let time_nonce = nonce_manager.next(signer_id)?;
let action_bytes = rmp_serde::to_vec_named(action)
.context("serialize action with MessagePack")
.map_err(|e| Error::bad_request(e.to_string()))?;
let sig = signer
.sign(&SignRequest {
action: None,
action_bytes: Some(action_bytes),
time_nonce,
action_type: HyperliquidActionType::L1,
is_testnet: self.is_testnet(),
vault_address: self.vault_address,
expires_after,
})?
.signature;
let mut request = if let Some(vault) = self.vault_address {
HyperliquidExchangeRequest::with_vault(
action.clone(),
time_nonce.as_millis() as u64,
sig,
vault.to_string(),
)
} else {
HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
};
request.expires_after = expires_after;
Ok(request)
}
pub async fn post_action_exec(
&self,
action: &HyperliquidExecAction,
) -> Result<HyperliquidExchangeResponse> {
let w = exec_action_weight(action);
self.rest_limiter.acquire(w).await;
let request = self.sign_action_exec_request(action, None)?;
let response = self.http_roundtrip_exchange(&request).await?;
if response.status.is_success() {
let parsed_response: HyperliquidExchangeResponse =
serde_json::from_slice(&response.body).map_err(Error::Serde)?;
match &parsed_response {
HyperliquidExchangeResponse::Status {
status,
response: response_data,
} if status == "err" => {
let error_msg = response_data
.as_str()
.map_or_else(|| response_data.to_string(), |s| s.to_string());
log::error!("Hyperliquid API returned error: {error_msg}");
Err(Error::bad_request(format!("API error: {error_msg}")))
}
HyperliquidExchangeResponse::Error { error } => {
log::error!("Hyperliquid API returned error: {error}");
Err(Error::bad_request(format!("API error: {error}")))
}
_ => Ok(parsed_response),
}
} else if response.status.as_u16() == 429 {
let ra = self.parse_retry_after_simple(&response.headers);
Err(Error::rate_limit("exchange", w, ra))
} else {
let error_body = String::from_utf8_lossy(&response.body);
Err(Error::http(
response.status.as_u16(),
error_body.to_string(),
))
}
}
pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
self.rest_limiter.snapshot().await
}
async fn http_roundtrip_exchange<T>(
&self,
request: &HyperliquidExchangeRequest<T>,
) -> Result<HttpResponse>
where
T: serde::Serialize,
{
let url = &self.base_exchange;
let body = serde_json::to_string(&request).map_err(Error::Serde)?;
let body_bytes = body.into_bytes();
let response = self
.client
.request(
Method::POST,
url.clone(),
None,
None,
Some(body_bytes),
None,
None,
)
.await
.map_err(Error::from_http_client)?;
Ok(response)
}
}
#[derive(Debug, Clone)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(
module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
from_py_object
)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
)]
pub struct HyperliquidHttpClient {
pub(crate) inner: Arc<HyperliquidRawHttpClient>,
clock: &'static AtomicTime,
instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
asset_indices: Arc<AtomicMap<Ustr, u32>>,
spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
client_order_id_cloids: Arc<Mutex<AHashMap<ClientOrderId, Cloid>>>,
account_id: Option<AccountId>,
account_address: Option<String>,
normalize_prices: bool,
market_order_slippage_bps: u32,
include_builder_attribution: bool,
}
impl Default for HyperliquidHttpClient {
fn default() -> Self {
Self::new(HyperliquidEnvironment::Mainnet, 60, None)
.expect("Failed to create default Hyperliquid HTTP client")
}
}
impl HyperliquidHttpClient {
pub fn new(
environment: HyperliquidEnvironment,
timeout_secs: u64,
proxy_url: Option<String>,
) -> std::result::Result<Self, HttpClientError> {
let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
Ok(Self::from_raw(raw_client))
}
pub fn with_secrets(
secrets: &Secrets,
timeout_secs: u64,
proxy_url: Option<String>,
) -> std::result::Result<Self, HttpClientError> {
let raw_client =
HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
Ok(Self::from_raw(raw_client))
}
fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
Self {
inner: Arc::new(raw_client),
clock: get_atomic_clock_realtime(),
instruments: Arc::new(AtomicMap::new()),
instruments_by_coin: Arc::new(AtomicMap::new()),
asset_indices: Arc::new(AtomicMap::new()),
spot_fill_coins: Arc::new(AtomicMap::new()),
client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
account_id: None,
account_address: None,
normalize_prices: true,
market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
include_builder_attribution: true,
}
}
#[allow(
clippy::missing_panics_doc,
reason = "cloid cache mutex poisoning is not expected"
)]
#[must_use]
pub fn get_or_generate_client_order_id_cloid(&self, client_order_id: ClientOrderId) -> Cloid {
let mut cloids = self.client_order_id_cloids.lock().expect(MUTEX_POISONED);
*cloids
.entry(client_order_id)
.or_insert_with(|| Cloid::from_client_order_id(client_order_id))
}
#[allow(
clippy::missing_panics_doc,
reason = "cloid cache mutex poisoning is not expected"
)]
pub fn cache_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
self.client_order_id_cloids
.lock()
.expect(MUTEX_POISONED)
.entry(client_order_id)
.or_insert(cloid);
}
#[allow(
clippy::missing_panics_doc,
reason = "cloid cache mutex poisoning is not expected"
)]
#[must_use]
pub fn cached_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
self.client_order_id_cloids
.lock()
.expect(MUTEX_POISONED)
.get(client_order_id)
.copied()
}
#[allow(
clippy::missing_panics_doc,
reason = "cloid cache mutex poisoning is not expected"
)]
pub fn remove_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
self.client_order_id_cloids
.lock()
.expect(MUTEX_POISONED)
.remove(client_order_id)
}
pub fn set_base_info_url(&mut self, url: String) {
Arc::get_mut(&mut self.inner)
.expect("cannot override URL: Arc has multiple references")
.set_base_info_url(url);
}
pub fn set_base_exchange_url(&mut self, url: String) {
Arc::get_mut(&mut self.inner)
.expect("cannot override URL: Arc has multiple references")
.set_base_exchange_url(url);
}
pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
Ok(Self {
inner: Arc::new(raw_client),
clock: get_atomic_clock_realtime(),
instruments: Arc::new(AtomicMap::new()),
instruments_by_coin: Arc::new(AtomicMap::new()),
asset_indices: Arc::new(AtomicMap::new()),
spot_fill_coins: Arc::new(AtomicMap::new()),
client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
account_id: None,
account_address: None,
normalize_prices: true,
market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
include_builder_attribution: true,
})
}
pub fn with_credentials(
private_key: Option<String>,
vault_address: Option<String>,
account_address: Option<&str>,
environment: HyperliquidEnvironment,
timeout_secs: u64,
proxy_url: Option<String>,
) -> Result<Self> {
let (pk_env_var, vault_env_var) = credential_env_vars(environment);
let resolved_account_address = resolve_execution_account_address(
private_key.as_deref(),
vault_address.as_deref(),
account_address,
environment,
)?;
let resolved_pk = private_key.or_else(|| std::env::var(pk_env_var).ok());
let resolved_vault = vault_address.or_else(|| std::env::var(vault_env_var).ok());
Self::from_resolved_credentials(
resolved_pk,
resolved_vault.as_deref(),
resolved_account_address,
environment,
timeout_secs,
proxy_url,
)
}
fn from_resolved_credentials(
private_key: Option<String>,
vault_address: Option<&str>,
account_address: Option<String>,
environment: HyperliquidEnvironment,
timeout_secs: u64,
proxy_url: Option<String>,
) -> Result<Self> {
match private_key {
Some(pk) => {
let raw_client = HyperliquidRawHttpClient::from_credentials(
&pk,
vault_address,
environment,
timeout_secs,
proxy_url,
)?;
Ok(Self {
inner: Arc::new(raw_client),
clock: get_atomic_clock_realtime(),
instruments: Arc::new(AtomicMap::new()),
instruments_by_coin: Arc::new(AtomicMap::new()),
asset_indices: Arc::new(AtomicMap::new()),
spot_fill_coins: Arc::new(AtomicMap::new()),
client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
account_id: None,
account_address,
normalize_prices: true,
market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
include_builder_attribution: true,
})
}
None => {
let mut client = Self::new(environment, timeout_secs, proxy_url)
.map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))?;
client.set_account_address(account_address);
Ok(client)
}
}
}
pub fn from_credentials(
private_key: &str,
vault_address: Option<&str>,
environment: HyperliquidEnvironment,
timeout_secs: u64,
proxy_url: Option<String>,
) -> Result<Self> {
let raw_client = HyperliquidRawHttpClient::from_credentials(
private_key,
vault_address,
environment,
timeout_secs,
proxy_url,
)?;
Ok(Self {
inner: Arc::new(raw_client),
clock: get_atomic_clock_realtime(),
instruments: Arc::new(AtomicMap::new()),
instruments_by_coin: Arc::new(AtomicMap::new()),
asset_indices: Arc::new(AtomicMap::new()),
spot_fill_coins: Arc::new(AtomicMap::new()),
client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
account_id: None,
account_address: None,
normalize_prices: true,
market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
include_builder_attribution: true,
})
}
#[must_use]
pub fn is_testnet(&self) -> bool {
self.inner.is_testnet()
}
#[must_use]
pub fn normalize_prices(&self) -> bool {
self.normalize_prices
}
pub fn set_normalize_prices(&mut self, value: bool) {
self.normalize_prices = value;
}
#[must_use]
pub fn market_order_slippage_bps(&self) -> u32 {
self.market_order_slippage_bps
}
pub fn set_market_order_slippage_bps(&mut self, value: u32) {
self.market_order_slippage_bps = value;
}
#[must_use]
pub fn include_builder_attribution(&self) -> bool {
self.include_builder_attribution
}
pub fn set_include_builder_attribution(&mut self, value: bool) {
self.include_builder_attribution = value;
}
pub fn get_user_address(&self) -> Result<String> {
self.inner.get_user_address()
}
#[must_use]
pub fn has_vault_address(&self) -> bool {
self.inner.has_vault_address()
}
#[must_use]
pub fn builder_attribution(&self) -> Option<HyperliquidExecBuilderFee> {
if !self.include_builder_attribution || self.has_vault_address() || self.is_testnet() {
None
} else {
Some(HyperliquidExecBuilderFee {
address: NAUTILUS_BUILDER_ADDRESS.to_string(),
fee_tenths_bp: 0,
})
}
}
pub fn get_account_address(&self) -> Result<String> {
if let Some(addr) = &self.account_address {
return Ok(addr.clone());
}
self.inner.get_account_address()
}
pub fn set_account_address(&mut self, address: Option<String>) {
self.account_address = address;
}
pub fn cache_instrument(&self, instrument: &InstrumentAny) {
let full_symbol = instrument.symbol().inner();
let coin = instrument.raw_symbol().inner();
self.instruments.rcu(|m| {
m.insert(full_symbol, instrument.clone());
m.insert(coin, instrument.clone());
});
if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
self.instruments_by_coin.rcu(|m| {
m.insert((coin, product_type), instrument.clone());
if let Some(alias_ustr) = cache_alias_for_symbol(full_symbol.as_str())
.map(|alias| Ustr::from(alias.as_str()))
{
let key = (alias_ustr, product_type);
if alias_ustr != coin && !m.contains_key(&key) {
m.insert(key, instrument.clone());
}
}
});
} else {
log::warn!("Unable to determine product type for symbol: {full_symbol}");
}
}
fn get_or_create_instrument(
&self,
coin: &Ustr,
product_type: Option<HyperliquidProductType>,
) -> Option<InstrumentAny> {
if let Some(pt) = product_type
&& let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
{
return Some(instrument.clone());
}
if product_type.is_none() {
let guard = self.instruments_by_coin.load();
if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
return Some(instrument.clone());
}
if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
return Some(instrument.clone());
}
if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
return Some(instrument.clone());
}
}
if coin.as_str().starts_with('@')
&& let Some(symbol) = self.spot_fill_coins.load().get(coin)
{
if let Some(instrument) = self.instruments.load().get(symbol) {
return Some(instrument.clone());
}
}
if coin.as_str().starts_with("vntls:") {
log::info!("Creating synthetic instrument for vault token: {coin}");
let ts_event = self.clock.get_time_ns();
let symbol_str = format!("{coin}-USDC-SPOT");
let symbol = Symbol::new(&symbol_str);
let venue = *HYPERLIQUID_VENUE;
let instrument_id = InstrumentId::new(symbol, venue);
let base_currency = Currency::new(
coin.as_str(),
8, 0, coin.as_str(),
CurrencyType::Crypto,
);
let quote_currency = Currency::new(
"USDC",
6, 0,
"USDC",
CurrencyType::Crypto,
);
let price_increment = Price::from("0.00000001");
let size_increment = Quantity::from("0.00000001");
let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
instrument_id,
symbol,
base_currency,
quote_currency,
8, 8, price_increment,
size_increment,
None, None, None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
ts_event,
));
self.cache_instrument(&instrument);
Some(instrument)
} else {
log::warn!("Instrument not found in cache: {coin}");
None
}
}
pub fn set_account_id(&mut self, account_id: AccountId) {
self.account_id = Some(account_id);
}
pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
let spot_meta = match self.inner.get_spot_meta().await {
Ok(spot_meta) => Some(spot_meta),
Err(e) => {
log::warn!("Failed to load Hyperliquid spot metadata: {e}");
None
}
};
match self.inner.load_all_perp_metas().await {
Ok(all_metas) => {
for (dex_index, meta) in all_metas.iter().enumerate() {
let base = perp_dex_asset_index_base(dex_index);
let settlement_currency = match resolve_perp_settlement_currency(
meta,
spot_meta.as_ref(),
) {
Ok(settlement_currency) => settlement_currency,
Err(e) => {
return Err(Error::decode(format!(
"failed to resolve perp settlement currency for dex {dex_index}: {e}",
)));
}
};
let perp_defs = parse_perp_instruments_with_settlement(
meta,
base,
settlement_currency.as_str(),
);
log::debug!(
"Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
perp_defs.len(),
);
defs.extend(perp_defs);
}
}
Err(e) => {
log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
match self.inner.load_perp_meta().await {
Ok(perp_meta) => {
match resolve_perp_settlement_currency(&perp_meta, spot_meta.as_ref()) {
Ok(settlement_currency) => {
let perp_defs = parse_perp_instruments_with_settlement(
&perp_meta,
0,
settlement_currency.as_str(),
);
log::debug!(
"Loaded Hyperliquid perp defs via fallback: count={}",
perp_defs.len(),
);
defs.extend(perp_defs);
}
Err(e) => {
return Err(Error::decode(format!(
"failed to resolve fallback perp settlement currency: {e}",
)));
}
}
}
Err(e) => {
log::warn!("Failed to load Hyperliquid perp metadata: {e}");
}
}
}
}
if let Some(spot_meta) = spot_meta.as_ref() {
match parse_spot_instruments(spot_meta) {
Ok(spot_defs) => {
log::debug!(
"Loaded Hyperliquid spot definitions: count={}",
spot_defs.len(),
);
defs.extend(spot_defs);
}
Err(e) => {
log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
}
}
}
match self.inner.get_outcome_meta().await {
Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
Ok(outcome_defs) => {
log::debug!(
"Loaded Hyperliquid outcome definitions: count={}",
outcome_defs.len(),
);
defs.extend(outcome_defs);
}
Err(e) => {
log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
}
},
Err(e) => {
log::debug!("Skipping Hyperliquid outcome metadata: {e}");
}
}
let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
for def in defs {
if seen_symbols.insert(def.symbol) {
deduped.push(def);
} else {
log::warn!(
"Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
def.symbol,
def.raw_symbol,
);
}
}
let defs = deduped;
self.asset_indices.rcu(|m| {
for def in &defs {
m.insert(def.symbol, def.asset_index);
}
});
log::debug!(
"Populated asset indices map (count={})",
self.asset_indices.len()
);
Ok(defs)
}
pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
let ts_init = self.clock.get_time_ns();
instruments_from_defs_owned(defs, ts_init)
}
pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
let defs = self.request_instrument_defs().await?;
Ok(self.convert_defs(defs))
}
pub async fn build_all_dex_asset_ctxs_instrument_ids(
&self,
) -> Result<AHashMap<String, Vec<Option<InstrumentId>>>> {
let all_metas = match self.inner.load_all_perp_metas().await {
Ok(all_metas) => all_metas,
Err(e) => {
log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
vec![self.inner.load_perp_meta().await?]
}
};
let perp_dexs = match self.inner.load_perp_dexs().await {
Ok(dexs) => Some(dexs),
Err(e) => {
log::warn!("Failed to load perpDexs, inferring dex names from metadata: {e}");
None
}
};
let raw_symbol_to_id =
self.instruments
.load()
.values()
.fold(AHashMap::new(), |mut acc, instrument| {
acc.insert(instrument.raw_symbol().to_string(), instrument.id());
acc
});
let mut mapping = AHashMap::new();
for (dex_index, meta) in all_metas.iter().enumerate() {
let dex_name = resolve_perp_dex_name(dex_index, meta, perp_dexs.as_deref());
let mut instrument_ids = Vec::with_capacity(meta.universe.len());
for asset in &meta.universe {
if let Some(instrument_id) = raw_symbol_to_id.get(&asset.name) {
instrument_ids.push(Some(*instrument_id));
} else {
log::warn!(
"Missing cached Hyperliquid instrument for dex='{}' raw_symbol='{}'",
dex_name,
asset.name
);
instrument_ids.push(None);
}
}
mapping.insert(dex_name, instrument_ids);
}
Ok(mapping)
}
pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
self.get_asset_index_for_symbol(Ustr::from(symbol))
}
pub(crate) fn get_asset_index_for_symbol(&self, symbol: Ustr) -> Option<u32> {
self.asset_indices.load().get(&symbol).copied()
}
pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
self.get_price_precision_for_symbol(Ustr::from(symbol))
}
pub(crate) fn get_price_precision_for_symbol(&self, symbol: Ustr) -> Option<u8> {
self.instruments
.load()
.get(&symbol)
.map(|inst| inst.price_precision())
}
#[must_use]
pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
const SPOT_INDEX_OFFSET: u32 = 10_000;
const BUILDER_PERP_OFFSET: u32 = 100_000;
let guard = self.asset_indices.load();
let mut mapping = AHashMap::new();
for (symbol, &asset_index) in guard.iter() {
if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
let pair_index = asset_index - SPOT_INDEX_OFFSET;
let fill_coin = Ustr::from(&format!("@{pair_index}"));
mapping.insert(fill_coin, *symbol);
}
}
self.spot_fill_coins.store(mapping.clone());
mapping
}
#[allow(dead_code)]
pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
self.inner.load_perp_meta().await
}
#[allow(dead_code)]
pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
self.inner.load_all_perp_metas().await
}
#[allow(dead_code)]
pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
self.inner.get_spot_meta().await
}
pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
self.inner.get_outcome_meta().await
}
pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
self.inner.info_l2_book(coin).await
}
pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
self.inner.info_recent_trades(coin).await
}
pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
self.inner.info_user_fills(user).await
}
pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
self.inner.info_order_status(user, oid).await
}
pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
self.inner.info_open_orders(user).await
}
pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
self.inner.info_frontend_open_orders(user).await
}
pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
self.inner.info_clearinghouse_state(user).await
}
pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
self.inner.info_spot_clearinghouse_state(user).await
}
pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
self.inner.info_user_fees(user).await
}
pub async fn info_candle_snapshot(
&self,
coin: &str,
interval: HyperliquidBarInterval,
start_time: u64,
end_time: u64,
) -> Result<HyperliquidCandleSnapshot> {
self.inner
.info_candle_snapshot(coin, interval, start_time, end_time)
.await
}
pub async fn info_funding_history(
&self,
coin: &str,
start_time: u64,
end_time: Option<u64>,
) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
self.inner
.info_funding_history(coin, start_time, end_time)
.await
}
pub async fn post_action(
&self,
action: &ExchangeAction,
) -> Result<HyperliquidExchangeResponse> {
self.inner.post_action(action).await
}
pub async fn post_action_exec(
&self,
action: &HyperliquidExecAction,
) -> Result<HyperliquidExchangeResponse> {
self.inner.post_action_exec(action).await
}
pub fn sign_action_exec_request(
&self,
action: &HyperliquidExecAction,
expires_after: Option<u64>,
) -> Result<HyperliquidExchangeRequest<HyperliquidExecAction>> {
self.inner.sign_action_exec_request(action, expires_after)
}
pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
self.inner.info_meta().await
}
pub async fn cancel_order(
&self,
instrument_id: InstrumentId,
client_order_id: Option<ClientOrderId>,
venue_order_id: Option<VenueOrderId>,
) -> Result<()> {
let symbol = instrument_id.symbol.inner();
let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
Error::bad_request(format!(
"Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
))
})?;
let action = if let Some(client_order_id) = client_order_id {
if let Some(cloid) = self.cached_client_order_id_cloid(&client_order_id) {
HyperliquidExecAction::CancelByCloid {
cancels: vec![HyperliquidExecCancelByCloidRequest {
asset: asset_id,
cloid,
}],
}
} else if let Some(oid) = venue_order_id {
let oid_u64 = oid
.as_str()
.parse::<u64>()
.map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
HyperliquidExecAction::Cancel {
cancels: vec![HyperliquidExecCancelOrderRequest {
asset: asset_id,
oid: oid_u64,
}],
}
} else {
let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
HyperliquidExecAction::CancelByCloid {
cancels: vec![HyperliquidExecCancelByCloidRequest {
asset: asset_id,
cloid,
}],
}
}
} else if let Some(oid) = venue_order_id {
let oid_u64 = oid
.as_str()
.parse::<u64>()
.map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
HyperliquidExecAction::Cancel {
cancels: vec![HyperliquidExecCancelOrderRequest {
asset: asset_id,
oid: oid_u64,
}],
}
} else {
return Err(Error::bad_request(
"Either client_order_id or venue_order_id must be provided",
));
};
let response = self.inner.post_action_exec(&action).await?;
match response {
ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
HyperliquidExchangeResponse::Status {
status,
response: error_data,
} => Err(Error::bad_request(format!(
"Cancel order failed: status={status}, error={error_data}"
))),
HyperliquidExchangeResponse::Error { error } => {
Err(Error::bad_request(format!("Cancel order error: {error}")))
}
}
}
#[expect(clippy::too_many_arguments)]
pub async fn modify_order(
&self,
instrument_id: InstrumentId,
venue_order_id: VenueOrderId,
order_side: OrderSide,
order_type: OrderType,
price: Price,
quantity: Quantity,
trigger_price: Option<Price>,
reduce_only: bool,
post_only: bool,
time_in_force: TimeInForce,
client_order_id: Option<ClientOrderId>,
) -> Result<()> {
let symbol = instrument_id.symbol.inner();
let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
Error::bad_request(format!(
"Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
))
})?;
let oid: u64 = venue_order_id
.as_str()
.parse()
.map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
let is_buy = matches!(order_side, OrderSide::Buy);
let decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
let normalized_price = if self.normalize_prices {
normalize_price(price.as_decimal(), decimals).normalize()
} else {
price.as_decimal().normalize()
};
let size = quantity.as_decimal().normalize();
let kind = match order_type {
OrderType::Market => HyperliquidExecOrderKind::Limit {
limit: HyperliquidExecLimitParams {
tif: HyperliquidExecTif::Ioc,
},
},
OrderType::Limit => {
let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
.map_err(|e| Error::bad_request(format!("{e}")))?;
HyperliquidExecOrderKind::Limit {
limit: HyperliquidExecLimitParams { tif },
}
}
OrderType::StopMarket
| OrderType::StopLimit
| OrderType::MarketIfTouched
| OrderType::LimitIfTouched => {
if let Some(trig_px) = trigger_price {
let trigger_price_decimal = if self.normalize_prices {
normalize_price(trig_px.as_decimal(), decimals).normalize()
} else {
trig_px.as_decimal().normalize()
};
let tpsl = match order_type {
OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
_ => HyperliquidExecTpSl::Tp,
};
let is_market = matches!(
order_type,
OrderType::StopMarket | OrderType::MarketIfTouched
);
HyperliquidExecOrderKind::Trigger {
trigger: HyperliquidExecTriggerParams {
is_market,
trigger_px: trigger_price_decimal,
tpsl,
},
}
} else {
return Err(Error::bad_request("Trigger orders require a trigger price"));
}
}
_ => {
return Err(Error::bad_request(format!(
"Order type {order_type:?} not supported for modify"
)));
}
};
let cloid = client_order_id.map(|id| self.get_or_generate_client_order_id_cloid(id));
let order = HyperliquidExecPlaceOrderRequest {
asset: asset_id,
is_buy,
price: normalized_price,
size,
reduce_only,
kind,
cloid,
};
let action = HyperliquidExecAction::Modify {
modify: HyperliquidExecModifyOrderRequest { oid, order },
};
let response = self.inner.post_action_exec(&action).await?;
match response {
ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
if let Some(inner_error) = extract_inner_error(&response) {
Err(Error::bad_request(format!(
"Modify order rejected: {inner_error}",
)))
} else {
Ok(())
}
}
HyperliquidExchangeResponse::Status {
status,
response: error_data,
} => Err(Error::bad_request(format!(
"Modify order failed: status={status}, error={error_data}"
))),
HyperliquidExchangeResponse::Error { error } => {
Err(Error::bad_request(format!("Modify order error: {error}")))
}
}
}
pub async fn submit_split_outcome(
&self,
outcome: u32,
amount: Decimal,
) -> Result<HyperliquidExchangeResponse> {
let action = HyperliquidExecAction::UserOutcome {
op: HyperliquidExecUserOutcomeOp::SplitOutcome(HyperliquidExecSplitOutcomeParams {
outcome,
amount,
}),
};
self.inner.post_action_exec(&action).await
}
pub async fn submit_merge_outcome(
&self,
outcome: u32,
amount: Option<Decimal>,
) -> Result<HyperliquidExchangeResponse> {
let action = HyperliquidExecAction::UserOutcome {
op: HyperliquidExecUserOutcomeOp::MergeOutcome(HyperliquidExecMergeOutcomeParams {
outcome,
amount,
}),
};
self.inner.post_action_exec(&action).await
}
pub async fn submit_merge_question(
&self,
question: u32,
amount: Option<Decimal>,
) -> Result<HyperliquidExchangeResponse> {
let action = HyperliquidExecAction::UserOutcome {
op: HyperliquidExecUserOutcomeOp::MergeQuestion(HyperliquidExecMergeQuestionParams {
question,
amount,
}),
};
self.inner.post_action_exec(&action).await
}
pub async fn submit_negate_outcome(
&self,
question: u32,
outcome: u32,
amount: Decimal,
) -> Result<HyperliquidExchangeResponse> {
let action = HyperliquidExecAction::UserOutcome {
op: HyperliquidExecUserOutcomeOp::NegateOutcome(HyperliquidExecNegateOutcomeParams {
question,
outcome,
amount,
}),
};
self.inner.post_action_exec(&action).await
}
pub async fn request_order_status_reports(
&self,
user: &str,
instrument_id: Option<InstrumentId>,
) -> Result<Vec<OrderStatusReport>> {
let account_id = self
.account_id
.ok_or_else(|| Error::bad_request("Account ID not set"))?;
let response = self.info_frontend_open_orders(user).await?;
let orders: Vec<serde_json::Value> = serde_json::from_value(response)
.map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
let mut reports = Vec::new();
let ts_init = self.clock.get_time_ns();
for order_value in orders {
let order: WsBasicOrderData = match serde_json::from_value(order_value.clone()) {
Ok(o) => o,
Err(e) => {
log::warn!("Failed to parse order: {e}");
continue;
}
};
let instrument = match self.get_or_create_instrument(&order.coin, None) {
Some(inst) => inst,
None => continue, };
if let Some(filter_id) = instrument_id
&& instrument.id() != filter_id
{
continue;
}
let status = HyperliquidOrderStatusEnum::Open;
match parse_order_status_report_from_basic(
&order,
&status,
&instrument,
account_id,
ts_init,
) {
Ok(report) => reports.push(report),
Err(e) => log::error!("Failed to parse order status report: {e}"),
}
}
Ok(reports)
}
pub async fn request_order_status_report(
&self,
user: &str,
oid: u64,
) -> Result<Option<OrderStatusReport>> {
let account_id = self
.account_id
.ok_or_else(|| Error::bad_request("Account ID not set"))?;
let ts_init = self.clock.get_time_ns();
let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
Ok(response) => match serde_json::from_value(response) {
Ok(v) => v,
Err(e) => {
log::warn!("Failed to parse frontend open orders response: {e}");
Vec::new()
}
},
Err(e) => {
log::warn!(
"Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
);
Vec::new()
}
};
if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
let instrument = match self.get_or_create_instrument(&order.coin, None) {
Some(inst) => inst,
None => return Ok(None),
};
let status = if order.trigger_activated == Some(true) {
HyperliquidOrderStatusEnum::Triggered
} else {
HyperliquidOrderStatusEnum::Open
};
return match parse_order_status_report_from_basic(
&order,
&status,
&instrument,
account_id,
ts_init,
) {
Ok(report) => Ok(Some(report)),
Err(e) => {
log::error!("Failed to parse order status report for oid {oid}: {e}");
Ok(None)
}
};
}
let response = self.info_order_status(user, oid).await?;
let entry = match response.into_order() {
Some(e) => e,
None => return Ok(None),
};
let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
Some(inst) => inst,
None => return Ok(None),
};
let basic = WsBasicOrderData {
coin: entry.order.coin,
side: entry.order.side,
limit_px: entry.order.limit_px,
sz: entry.order.sz,
oid: entry.order.oid,
timestamp: entry.order.timestamp,
orig_sz: entry.order.orig_sz,
cloid: entry.order.cloid,
tif: None,
reduce_only: None,
trigger_px: None,
is_market: None,
tpsl: None,
trigger_activated: None,
trailing_stop: None,
};
match parse_order_status_report_from_basic(
&basic,
&entry.status,
&instrument,
account_id,
ts_init,
) {
Ok(mut report) => {
if entry.status_timestamp > 0 {
report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
}
Ok(Some(report))
}
Err(e) => {
log::error!("Failed to parse order status report for oid {oid}: {e}");
Ok(None)
}
}
}
pub async fn request_order_status_report_by_client_order_id(
&self,
user: &str,
client_order_id: &ClientOrderId,
) -> Result<Option<OrderStatusReport>> {
let account_id = self
.account_id
.ok_or_else(|| Error::bad_request("Account ID not set"))?;
let ts_init = self.clock.get_time_ns();
let cached_cloid_hex = self
.cached_client_order_id_cloid(client_order_id)
.map(|cloid| cloid.to_hex());
let cloid = Cloid::from_client_order_id(*client_order_id);
let cloid_hex = cloid.to_hex();
let response = self.info_frontend_open_orders(user).await?;
let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
Ok(v) => v,
Err(e) => {
log::warn!("Failed to parse frontend open orders response: {e}");
return Ok(None);
}
};
let order = match orders.into_iter().find(|o| {
o.cloid
.as_ref()
.is_some_and(|c| cached_cloid_hex.as_ref() == Some(c) || c == &cloid_hex)
}) {
Some(o) => o,
None => return Ok(None),
};
let instrument = match self.get_or_create_instrument(&order.coin, None) {
Some(inst) => inst,
None => return Ok(None),
};
let status = if order.trigger_activated == Some(true) {
HyperliquidOrderStatusEnum::Triggered
} else {
HyperliquidOrderStatusEnum::Open
};
match parse_order_status_report_from_basic(
&order,
&status,
&instrument,
account_id,
ts_init,
) {
Ok(mut report) => {
report.client_order_id = Some(*client_order_id);
Ok(Some(report))
}
Err(e) => {
log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
Ok(None)
}
}
}
pub async fn request_fill_reports(
&self,
user: &str,
instrument_id: Option<InstrumentId>,
) -> Result<Vec<FillReport>> {
let account_id = self
.account_id
.ok_or_else(|| Error::bad_request("Account ID not set"))?;
let fills_response = self.info_user_fills(user).await?;
let mut reports = Vec::new();
let ts_init = self.clock.get_time_ns();
for fill in fills_response {
let instrument = match self.get_or_create_instrument(&fill.coin, None) {
Some(inst) => inst,
None => continue, };
if let Some(filter_id) = instrument_id
&& instrument.id() != filter_id
{
continue;
}
match parse_fill_report(&fill, &instrument, account_id, ts_init) {
Ok(report) => reports.push(report),
Err(e) => log::error!("Failed to parse fill report: {e}"),
}
}
Ok(reports)
}
pub async fn request_position_status_reports(
&self,
user: &str,
instrument_id: Option<InstrumentId>,
) -> Result<Vec<PositionStatusReport>> {
let account_id = self
.account_id
.ok_or_else(|| Error::bad_request("Account ID not set"))?;
let filter_product = instrument_id
.and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
let fetch_perp = !matches!(
filter_product,
Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
);
let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
let mut reports = Vec::new();
let ts_init = self.clock.get_time_ns();
if !fetch_perp {
let spot_reports = self
.request_spot_position_status_reports(user, instrument_id)
.await?;
reports.extend(spot_reports);
return Ok(reports);
}
let state_response = self.info_clearinghouse_state(user).await?;
let asset_positions: Vec<serde_json::Value> = state_response
.get("assetPositions")
.and_then(|v| v.as_array())
.ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
.clone();
for position_value in asset_positions {
let coin = position_value
.get("position")
.and_then(|p| p.get("coin"))
.and_then(|c| c.as_str())
.ok_or_else(|| Error::bad_request("coin not found in position"))?;
let coin_ustr = Ustr::from(coin);
let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
Some(inst) => inst,
None => continue, };
if let Some(filter_id) = instrument_id
&& instrument.id() != filter_id
{
continue;
}
match parse_position_status_report(&position_value, &instrument, account_id, ts_init) {
Ok(report) => reports.push(report),
Err(e) => log::error!("Failed to parse position status report: {e}"),
}
}
if fetch_spot {
let spot_reports = self
.request_spot_position_status_reports(user, instrument_id)
.await?;
reports.extend(spot_reports);
}
Ok(reports)
}
pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
let account_id = self
.account_id
.ok_or_else(|| Error::bad_request("Account ID not set"))?;
let state_response = self.info_clearinghouse_state(user).await?;
let ts_init = self.clock.get_time_ns();
log::trace!("Clearinghouse state response: {state_response}");
let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
.map_err(|e| {
log::error!("Failed to parse clearinghouse state: {e}");
log::debug!("Raw response: {state_response}");
Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
})?;
let spot_response = self.info_spot_clearinghouse_state(user).await?;
let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
.map_err(|e| {
log::error!("Failed to parse spot clearinghouse state: {e}");
log::debug!("Raw spot response: {spot_response}");
Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
})?;
let (balances, margins) =
parse_combined_account_balances_and_margins(&perp_state, &spot_state)
.map_err(|e| Error::decode(e.to_string()))?;
Ok(AccountState::new(
account_id,
AccountType::Margin,
balances,
margins,
true, UUID4::new(),
ts_init,
ts_init,
None,
))
}
pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
let response = self.info_spot_clearinghouse_state(user).await?;
log::trace!("Spot clearinghouse state response: {response}");
let state: SpotClearinghouseState =
serde_json::from_value(response.clone()).map_err(|e| {
log::error!("Failed to parse spot clearinghouse state: {e}");
log::debug!("Raw response: {response}");
Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
})?;
parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
}
pub async fn request_spot_position_status_reports(
&self,
user: &str,
instrument_id: Option<InstrumentId>,
) -> Result<Vec<PositionStatusReport>> {
let account_id = self
.account_id
.ok_or_else(|| Error::bad_request("Account ID not set"))?;
let response = self.info_spot_clearinghouse_state(user).await?;
let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
log::error!("Failed to parse spot clearinghouse state: {e}");
Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
})?;
let ts_init = self.clock.get_time_ns();
let mut reports = Vec::with_capacity(state.balances.len());
for balance in &state.balances {
if balance.total.is_zero() {
continue;
}
if balance.coin.as_str() == "USDC" {
continue;
}
let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
_ => HyperliquidProductType::Spot,
};
let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
{
Some(inst) => inst,
None => continue,
};
if let Some(filter_id) = instrument_id
&& instrument.id() != filter_id
{
continue;
}
match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
Ok(report) => reports.push(report),
Err(e) => log::error!(
"Failed to parse spot position status report for {}: {e}",
balance.coin,
),
}
}
Ok(reports)
}
pub async fn request_bars(
&self,
bar_type: BarType,
start: Option<chrono::DateTime<chrono::Utc>>,
end: Option<chrono::DateTime<chrono::Utc>>,
limit: Option<u32>,
) -> Result<Vec<Bar>> {
let instrument_id = bar_type.instrument_id();
let symbol = instrument_id.symbol;
let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
let alias = cache_alias_for_symbol(symbol.as_str())
.map(|alias| Ustr::from(alias.as_str()))
.ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
let instrument = self
.get_or_create_instrument(&alias, product_type)
.ok_or_else(|| {
Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
})?;
let coin = instrument.raw_symbol().inner();
let price_precision = instrument.price_precision();
let size_precision = instrument.size_precision();
let interval =
bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
let now = chrono::Utc::now();
let end_time = end.unwrap_or(now).timestamp_millis() as u64;
let start_time = if let Some(start) = start {
start.timestamp_millis() as u64
} else {
let spec = bar_type.spec();
let step_ms = match spec.aggregation {
BarAggregation::Minute => spec.step.get() as u64 * 60_000,
BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
_ => 60_000,
};
end_time.saturating_sub(1000 * step_ms)
};
let candles = self
.info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
.await?;
let now_ms = now.timestamp_millis() as u64;
let mut bars: Vec<Bar> = candles
.iter()
.filter(|candle| candle.end_timestamp < now_ms)
.enumerate()
.filter_map(|(i, candle)| {
candle_to_bar(candle, bar_type, price_precision, size_precision)
.map_err(|e| {
log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
e
})
.ok()
})
.collect();
if let Some(limit) = limit
&& limit > 0
&& bars.len() > limit as usize
{
bars.truncate(limit as usize);
}
log::debug!(
"Received {} bars for {} (filtered {} incomplete)",
bars.len(),
bar_type,
candles.len() - bars.len()
);
Ok(bars)
}
#[expect(clippy::too_many_arguments)]
pub async fn submit_order(
&self,
instrument_id: InstrumentId,
client_order_id: ClientOrderId,
order_side: OrderSide,
order_type: OrderType,
quantity: Quantity,
time_in_force: TimeInForce,
price: Option<Price>,
trigger_price: Option<Price>,
post_only: bool,
reduce_only: bool,
) -> Result<OrderStatusReport> {
let symbol = instrument_id.symbol.inner();
let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
Error::bad_request(format!(
"Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
))
})?;
let is_buy = matches!(order_side, OrderSide::Buy);
let price_precision = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
let price_decimal = match price {
Some(px) if self.normalize_prices => {
normalize_price(px.as_decimal(), price_precision).normalize()
}
Some(px) => px.as_decimal().normalize(),
None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
None if matches!(
order_type,
OrderType::StopMarket | OrderType::MarketIfTouched
) =>
{
match trigger_price {
Some(tp) => {
let derived = derive_limit_from_trigger(
tp.as_decimal().normalize(),
is_buy,
self.market_order_slippage_bps,
);
let sig_rounded = round_to_sig_figs(derived, 5);
clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
}
None => Decimal::ZERO,
}
}
None => return Err(Error::bad_request("Limit orders require a price")),
};
let size_decimal = quantity.as_decimal().normalize();
let kind = match order_type {
OrderType::Market => HyperliquidExecOrderKind::Limit {
limit: HyperliquidExecLimitParams {
tif: HyperliquidExecTif::Ioc,
},
},
OrderType::Limit => {
let tif = if post_only {
HyperliquidExecTif::Alo
} else {
match time_in_force {
TimeInForce::Gtc => HyperliquidExecTif::Gtc,
TimeInForce::Ioc => HyperliquidExecTif::Ioc,
TimeInForce::Fok
| TimeInForce::Day
| TimeInForce::Gtd
| TimeInForce::AtTheOpen
| TimeInForce::AtTheClose => {
return Err(Error::bad_request(format!(
"Time in force {time_in_force:?} not supported"
)));
}
}
};
HyperliquidExecOrderKind::Limit {
limit: HyperliquidExecLimitParams { tif },
}
}
OrderType::StopMarket
| OrderType::StopLimit
| OrderType::MarketIfTouched
| OrderType::LimitIfTouched => {
if let Some(trig_px) = trigger_price {
let trigger_price_decimal = if self.normalize_prices {
normalize_price(trig_px.as_decimal(), price_precision).normalize()
} else {
trig_px.as_decimal().normalize()
};
let tpsl = match order_type {
OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
HyperliquidExecTpSl::Tp
}
_ => unreachable!(),
};
let is_market = matches!(
order_type,
OrderType::StopMarket | OrderType::MarketIfTouched
);
HyperliquidExecOrderKind::Trigger {
trigger: HyperliquidExecTriggerParams {
is_market,
trigger_px: trigger_price_decimal,
tpsl,
},
}
} else {
return Err(Error::bad_request("Trigger orders require a trigger price"));
}
}
_ => {
return Err(Error::bad_request(format!(
"Order type {order_type:?} not supported"
)));
}
};
let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
let hyperliquid_order = HyperliquidExecPlaceOrderRequest {
asset,
is_buy,
price: price_decimal,
size: size_decimal,
reduce_only,
kind,
cloid: Some(cloid),
};
let builder = self.builder_attribution();
let action = HyperliquidExecAction::Order {
orders: vec![hyperliquid_order],
grouping: HyperliquidExecGrouping::Na,
builder,
};
let response = self.inner.post_action_exec(&action).await?;
self.build_submit_order_report(
instrument_id,
client_order_id,
order_side,
order_type,
quantity,
time_in_force,
price,
trigger_price,
response,
)?
.ok_or_else(|| {
Error::bad_request(
"Single-order submission returned no actionable status (deferred trigger child)",
)
})
}
pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
self.submit_order(
order.instrument_id(),
order.client_order_id(),
order.order_side(),
order.order_type(),
order.quantity(),
order.time_in_force(),
order.price(),
order.trigger_price(),
order.is_post_only(),
order.is_reduce_only(),
)
.await
}
#[expect(clippy::too_many_arguments)]
fn create_order_status_report(
&self,
instrument_id: InstrumentId,
client_order_id: Option<ClientOrderId>,
venue_order_id: VenueOrderId,
order_side: OrderSide,
order_type: OrderType,
quantity: Quantity,
time_in_force: TimeInForce,
price: Option<Price>,
trigger_price: Option<Price>,
order_status: OrderStatus,
filled_qty: Quantity,
_instrument: &InstrumentAny,
account_id: AccountId,
ts_init: UnixNanos,
) -> OrderStatusReport {
let ts_accepted = self.clock.get_time_ns();
let ts_last = ts_accepted;
let report_id = UUID4::new();
let mut report = OrderStatusReport::new(
account_id,
instrument_id,
client_order_id,
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(px) = price {
report = report.with_price(px);
}
if let Some(trig_px) = trigger_price {
report = report
.with_trigger_price(trig_px)
.with_trigger_type(TriggerType::Default);
}
report
}
pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
let mut hyperliquid_orders = Vec::with_capacity(orders.len());
let mut client_order_ids = Vec::with_capacity(orders.len());
for order in orders {
let instrument_id = order.instrument_id();
let symbol = instrument_id.symbol.inner();
let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
Error::bad_request(format!(
"Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
))
})?;
let price_decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
let request = order_to_hyperliquid_request_with_asset_and_cloid(
order,
asset,
price_decimals,
self.normalize_prices,
self.market_order_slippage_bps,
None,
)
.map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
client_order_ids.push(order.client_order_id());
hyperliquid_orders.push(request);
}
for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
request.cloid = Some(self.get_or_generate_client_order_id_cloid(client_order_id));
}
let builder = self.builder_attribution();
let grouping =
determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
let action = HyperliquidExecAction::Order {
orders: hyperliquid_orders,
grouping,
builder,
};
let response = self.inner.post_action_exec(&action).await?;
self.build_submit_orders_reports(orders, grouping, response)
}
#[expect(clippy::too_many_arguments)]
pub fn build_submit_order_report(
&self,
instrument_id: InstrumentId,
client_order_id: ClientOrderId,
order_side: OrderSide,
order_type: OrderType,
quantity: Quantity,
time_in_force: TimeInForce,
price: Option<Price>,
trigger_price: Option<Price>,
response: HyperliquidExchangeResponse,
) -> Result<Option<OrderStatusReport>> {
let order_response = parse_order_response(response)?;
let Some(order_status) = order_response.statuses.first() else {
return Ok(None);
};
let account_id = self
.account_id
.ok_or_else(|| Error::bad_request("Account ID not set"))?;
let ts_init = self.clock.get_time_ns();
self.build_status_report(
instrument_id,
client_order_id,
order_side,
order_type,
quantity,
time_in_force,
price,
trigger_price,
order_status,
account_id,
ts_init,
)
}
pub fn build_submit_orders_reports(
&self,
orders: &[&OrderAny],
grouping: HyperliquidExecGrouping,
response: HyperliquidExchangeResponse,
) -> Result<Vec<OrderStatusReport>> {
let order_response = parse_order_response(response)?;
let account_id = self
.account_id
.ok_or_else(|| Error::bad_request("Account ID not set"))?;
let ts_init = self.clock.get_time_ns();
if grouping == HyperliquidExecGrouping::Na && order_response.statuses.len() != orders.len()
{
return Err(Error::bad_request(format!(
"Mismatch between submitted orders ({}) and response statuses ({})",
orders.len(),
order_response.statuses.len()
)));
}
let mut reports = Vec::with_capacity(order_response.statuses.len());
for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
if let Some(report) = self.build_status_report(
order.instrument_id(),
order.client_order_id(),
order.order_side(),
order.order_type(),
order.quantity(),
order.time_in_force(),
order.price(),
order.trigger_price(),
order_status,
account_id,
ts_init,
)? {
reports.push(report);
}
}
Ok(reports)
}
#[expect(clippy::too_many_arguments)]
fn build_status_report(
&self,
instrument_id: InstrumentId,
client_order_id: ClientOrderId,
order_side: OrderSide,
order_type: OrderType,
quantity: Quantity,
time_in_force: TimeInForce,
price: Option<Price>,
trigger_price: Option<Price>,
order_status: &HyperliquidExecOrderStatus,
account_id: AccountId,
ts_init: UnixNanos,
) -> Result<Option<OrderStatusReport>> {
if matches!(order_status, HyperliquidExecOrderStatus::Tag(_)) {
return Ok(None);
}
let symbol = instrument_id.symbol.as_str();
let product_type = HyperliquidProductType::from_symbol(symbol).ok();
let asset = cache_alias_for_symbol(symbol).unwrap_or_else(|| symbol.to_string());
let instrument = self
.get_or_create_instrument(&Ustr::from(asset.as_str()), product_type)
.ok_or_else(|| {
Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
})?;
let report = match order_status {
HyperliquidExecOrderStatus::Resting { resting } => self.create_order_status_report(
instrument_id,
Some(client_order_id),
VenueOrderId::new(resting.oid.to_string()),
order_side,
order_type,
quantity,
time_in_force,
price,
trigger_price,
OrderStatus::Accepted,
Quantity::zero(instrument.size_precision()),
&instrument,
account_id,
ts_init,
),
HyperliquidExecOrderStatus::Filled { filled } => {
let filled_qty =
Quantity::from_decimal_dp(filled.total_sz, instrument.size_precision())
.map_err(|e| {
Error::bad_request(format!(
"Invalid filled size {}: {e}",
filled.total_sz
))
})?;
self.create_order_status_report(
instrument_id,
Some(client_order_id),
VenueOrderId::new(filled.oid.to_string()),
order_side,
order_type,
quantity,
time_in_force,
price,
trigger_price,
OrderStatus::Filled,
filled_qty,
&instrument,
account_id,
ts_init,
)
}
HyperliquidExecOrderStatus::Error { error } => {
return Err(Error::bad_request(format!(
"Order {client_order_id} rejected: {error}"
)));
}
HyperliquidExecOrderStatus::Tag(_) => unreachable!("handled above"),
};
Ok(Some(report))
}
}
fn parse_order_response(
response: HyperliquidExchangeResponse,
) -> Result<HyperliquidExecOrderResponseData> {
let response_data = match response {
HyperliquidExchangeResponse::Status {
status,
response: response_data,
} if status == RESPONSE_STATUS_OK => response_data,
HyperliquidExchangeResponse::Error { error } => {
return Err(Error::bad_request(format!(
"Order submission failed: {error}"
)));
}
_ => return Err(Error::bad_request("Unexpected response format")),
};
let data_value = if let Some(data) = response_data.get("data") {
data.clone()
} else {
response_data
};
serde_json::from_value(data_value)
.map_err(|e| Error::bad_request(format!("Failed to parse order response: {e}")))
}
fn resolve_perp_dex_name(
dex_index: usize,
meta: &PerpMeta,
perp_dexs: Option<&[Option<PerpDex>]>,
) -> String {
if dex_index == 0 {
return String::new();
}
if let Some(dex_name) = perp_dexs
.and_then(|dexs| dexs.get(dex_index))
.and_then(|dex| dex.as_ref())
.map(|dex| dex.name.clone())
{
return dex_name;
}
meta.universe
.iter()
.find_map(|asset| asset.name.split_once(':').map(|(dex, _)| dex.to_string()))
.unwrap_or_default()
}
fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
if dex_index == 0 {
0
} else {
100_000 + dex_index as u32 * 10_000
}
}
#[cfg(test)]
mod tests {
use std::{net::SocketAddr, sync::Arc};
use axum::{
Router,
extract::State,
http::StatusCode,
response::{IntoResponse, Json, Response},
routing::post,
};
use nautilus_core::{MUTEX_POISONED, time::get_atomic_clock_realtime};
use nautilus_model::{
currencies::CURRENCY_MAP,
enums::{CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce},
identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol},
instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
types::{Currency, Price, Quantity},
};
use rstest::rstest;
use rust_decimal_macros::dec;
use serde_json::{Value, json};
use ustr::Ustr;
use super::{HyperliquidHttpClient, resolve_perp_dex_name};
use crate::{
common::{
consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS},
enums::{HyperliquidEnvironment, HyperliquidProductType},
},
http::{
models::{Cloid, HyperliquidExchangeResponse, PerpAsset, PerpDex, PerpMeta},
query::InfoRequest,
},
};
const TEST_PRIVATE_KEY: &str =
"0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
fn perp_meta_with_assets(names: &[&str]) -> PerpMeta {
PerpMeta {
universe: names
.iter()
.map(|name| PerpAsset {
name: (*name).to_string(),
..Default::default()
})
.collect(),
margin_tables: Vec::new(),
collateral_token: None,
}
}
#[rstest]
fn resolve_perp_dex_name_uses_empty_string_for_default_dex() {
let meta = perp_meta_with_assets(&["BTC", "ETH"]);
assert_eq!(resolve_perp_dex_name(0, &meta, None), "");
}
#[rstest]
fn resolve_perp_dex_name_prefers_perp_dexs_entry() {
let meta = perp_meta_with_assets(&["xyz:TSLA"]);
let perp_dexs = vec![
None,
Some(PerpDex {
name: "xyz".to_string(),
}),
];
assert_eq!(resolve_perp_dex_name(1, &meta, Some(&perp_dexs)), "xyz");
}
#[rstest]
fn resolve_perp_dex_name_infers_from_asset_name_when_perp_dexs_missing() {
let meta = perp_meta_with_assets(&["abc:TSLA", "abc:NVDA"]);
assert_eq!(resolve_perp_dex_name(1, &meta, None), "abc");
}
#[rstest]
fn test_build_submit_order_report_elides_waiting_for_fill_tag() {
let mut client =
HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
client.set_account_id(AccountId::from("HYPERLIQUID-001"));
let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
"status": "ok",
"response": {
"type": "order",
"data": {
"statuses": ["waitingForFill"]
}
}
}))
.unwrap();
let result = client
.build_submit_order_report(
InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
ClientOrderId::from("O-WAITING-CHILD"),
OrderSide::Buy,
OrderType::StopMarket,
Quantity::from("100"),
TimeInForce::Gtc,
None,
Some(Price::from("0.16136")),
response,
)
.unwrap();
assert!(
result.is_none(),
"Tag status must elide so the order stays SUBMITTED, was {result:?}"
);
}
#[rstest]
fn test_build_submit_order_report_filled_uses_total_sz_decimal() {
let mut client =
HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
client.set_account_id(AccountId::from("HYPERLIQUID-001"));
let base = Currency::new("ARB", 8, 0, "ARB", CurrencyType::Crypto);
let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
let clock = get_atomic_clock_realtime();
let ts = clock.get_time_ns();
let perp = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
InstrumentId::new(Symbol::new("ARB-USD-PERP"), *HYPERLIQUID_VENUE),
Symbol::new("ARB"),
base,
usd,
usdc,
false,
5,
2,
Price::from("0.00001"),
Quantity::from("0.01"),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
ts,
ts,
));
client.cache_instrument(&perp);
let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
"status": "ok",
"response": {
"type": "order",
"data": {
"statuses": [{
"filled": {"totalSz": "0.5", "avgPx": "1.2345", "oid": 778899}
}]
}
}
}))
.unwrap();
let report = client
.build_submit_order_report(
InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
ClientOrderId::from("O-FILLED-001"),
OrderSide::Buy,
OrderType::Market,
Quantity::from("0.5"),
TimeInForce::Ioc,
None,
None,
response,
)
.unwrap()
.expect("filled status must produce a report");
assert_eq!(report.order_status, OrderStatus::Filled);
assert_eq!(report.venue_order_id.as_str(), "778899");
assert_eq!(report.filled_qty.as_decimal(), dec!(0.5));
}
#[derive(Clone, Default)]
struct OutcomeMetaServerState {
last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
}
async fn handle_outcome_meta_info(
State(state): State<OutcomeMetaServerState>,
body: axum::body::Bytes,
) -> Response {
let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "Invalid JSON body"})),
)
.into_response();
};
*state.last_request_body.lock().await = Some(request_body.clone());
if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "Expected outcomeMeta request"})),
)
.into_response();
}
Json(json!({
"outcomes": [
{
"outcome": 123,
"name": "Recurring",
"description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
"sideSpecs": [
{"name": "Yes"},
{"name": "No"}
]
}
]
}))
.into_response()
}
async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let router = Router::new()
.route("/info", post(handle_outcome_meta_info))
.with_state(state);
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
addr
}
async fn handle_unresolved_collateral_info(body: axum::body::Bytes) -> Response {
let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "Invalid JSON body"})),
)
.into_response();
};
match request_body.get("type").and_then(|value| value.as_str()) {
Some("spotMeta") => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "spot metadata unavailable"})),
)
.into_response(),
Some("allPerpMetas") => Json(json!([
{
"collateralToken": 360,
"marginTables": [],
"universe": [
{
"maxLeverage": 20,
"name": "km:US500",
"szDecimals": 3
}
]
}
]))
.into_response(),
_ => Json(json!({"universe": [], "marginTables": []})).into_response(),
}
}
async fn start_unresolved_collateral_server() -> SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let router = Router::new().route("/info", post(handle_unresolved_collateral_info));
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
addr
}
#[rstest]
fn stable_json_roundtrips() {
let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
let s = serde_json::to_string(&v).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed["type"], "l2Book");
assert_eq!(parsed["coin"], "BTC");
assert_eq!(parsed, v);
}
#[rstest]
fn info_pretty_shape() {
let r = InfoRequest::l2_book("BTC");
let val = serde_json::to_value(&r).unwrap();
let pretty = serde_json::to_string_pretty(&val).unwrap();
assert!(pretty.contains("\"type\": \"l2Book\""));
assert!(pretty.contains("\"coin\": \"BTC\""));
}
#[rstest]
fn test_client_order_id_cloid_cache_is_stable_and_first_write_wins() {
let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
let client_order_id = ClientOrderId::new("O-CLOID-CACHE");
let other_client_order_id = ClientOrderId::new("O-CLOID-CACHE-OTHER");
let explicit_cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
let first = client.get_or_generate_client_order_id_cloid(client_order_id);
let second = client.get_or_generate_client_order_id_cloid(client_order_id);
client.cache_client_order_id_cloid(client_order_id, explicit_cloid);
client.cache_client_order_id_cloid(other_client_order_id, explicit_cloid);
assert_eq!(first, Cloid::from_client_order_id(client_order_id));
assert_eq!(first, second);
assert_eq!(
client.cached_client_order_id_cloid(&client_order_id),
Some(first),
"cache insert must not overwrite an existing generated CLOID",
);
assert_eq!(
client.cached_client_order_id_cloid(&other_client_order_id),
Some(explicit_cloid),
);
assert_eq!(
client.remove_client_order_id_cloid(&client_order_id),
Some(first),
);
assert_eq!(client.cached_client_order_id_cloid(&client_order_id), None);
}
#[rstest]
fn test_builder_attribution_defaults_to_mainnet_builder() {
let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
let builder = client
.builder_attribution()
.expect("mainnet client should include builder attribution by default");
assert!(client.include_builder_attribution());
assert_eq!(builder.address, NAUTILUS_BUILDER_ADDRESS);
assert_eq!(builder.fee_tenths_bp, 0);
}
#[rstest]
fn test_builder_attribution_disabled_returns_none() {
let mut client =
HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
client.set_include_builder_attribution(false);
assert!(!client.include_builder_attribution());
assert!(client.builder_attribution().is_none());
}
#[rstest]
fn test_builder_attribution_omitted_on_testnet() {
let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
assert!(client.include_builder_attribution());
assert!(client.builder_attribution().is_none());
}
#[rstest]
#[tokio::test]
async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
let state = OutcomeMetaServerState::default();
let addr = start_outcome_meta_server(state.clone()).await;
let mut client =
HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
client.set_base_info_url(format!("http://{addr}/info"));
let meta = client.get_outcome_meta().await.unwrap();
let request_body = state.last_request_body.lock().await.clone().unwrap();
assert_eq!(request_body, json!({"type": "outcomeMeta"}));
assert_eq!(meta.outcomes.len(), 1);
assert_eq!(meta.outcomes[0].outcome, 123);
assert_eq!(meta.outcomes[0].name, "Recurring");
assert_eq!(meta.outcomes[0].side_specs.len(), 2);
assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
}
#[rstest]
#[tokio::test]
async fn test_request_instrument_defs_errors_when_non_usdc_collateral_unresolved() {
let addr = start_unresolved_collateral_server().await;
let mut client =
HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
client.set_base_info_url(format!("http://{addr}/info"));
let err = client.request_instrument_defs().await.unwrap_err();
assert_eq!(
err.to_string(),
"decode error: failed to resolve perp settlement currency for dex 0: \
Spot metadata required to resolve perp collateral token 360",
);
}
#[rstest]
fn test_with_credentials_preserves_explicit_account_address() {
let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let client = HyperliquidHttpClient::with_credentials(
Some(TEST_PRIVATE_KEY.to_string()),
None,
Some(account_address),
HyperliquidEnvironment::Mainnet,
60,
None,
)
.unwrap();
assert_eq!(client.get_account_address().unwrap(), account_address);
}
#[rstest]
fn test_from_resolved_credentials_preserves_account_address_without_private_key() {
let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let client = HyperliquidHttpClient::from_resolved_credentials(
None,
None,
Some(account_address.to_string()),
HyperliquidEnvironment::Mainnet,
60,
None,
)
.unwrap();
assert_eq!(client.get_account_address().unwrap(), account_address);
}
#[rstest]
fn test_cache_instrument_by_raw_symbol() {
let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
let base_code = "vntls:vCURSOR";
let quote_code = "USDC";
{
let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
if !currency_map.contains_key(base_code) {
currency_map.insert(
base_code.to_string(),
Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
);
}
}
let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
let venue = *HYPERLIQUID_VENUE;
let instrument_id = InstrumentId::new(symbol, venue);
let raw_symbol = Symbol::new(base_code);
let clock = get_atomic_clock_realtime();
let ts = clock.get_time_ns();
let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
instrument_id,
raw_symbol,
base_currency,
quote_currency,
8,
8,
Price::from("0.00000001"),
Quantity::from("0.00000001"),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, None, None, None, ts,
ts,
));
client.cache_instrument(&instrument);
let instruments = client.instruments.load();
let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
assert!(
by_full_symbol.is_some(),
"Instrument should be accessible by full symbol"
);
assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
assert!(
by_raw_symbol.is_some(),
"Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
);
assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
drop(instruments);
let instruments_by_coin = client.instruments_by_coin.load();
let by_coin =
instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
assert!(
by_coin.is_some(),
"Instrument should be accessible by coin and product type"
);
assert_eq!(by_coin.unwrap().id(), instrument.id());
drop(instruments_by_coin);
let retrieved_with_type = client.get_or_create_instrument(
&Ustr::from("vntls:vCURSOR"),
Some(HyperliquidProductType::Spot),
);
assert!(retrieved_with_type.is_some());
assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
let retrieved_without_type =
client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
assert!(retrieved_without_type.is_some());
assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
}
#[rstest]
fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
use nautilus_core::time::get_atomic_clock_realtime;
use nautilus_model::{
enums::AssetClass,
identifiers::{InstrumentId, Symbol},
instruments::{BinaryOption, InstrumentAny},
types::{Currency, Price, Quantity},
};
let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
let coin = "#500";
let token = "+500";
let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
let symbol = Symbol::new(token);
let raw_symbol = Symbol::new(coin);
let venue = *HYPERLIQUID_VENUE;
let instrument_id = InstrumentId::new(symbol, venue);
let clock = get_atomic_clock_realtime();
let ts = clock.get_time_ns();
let binary = InstrumentAny::BinaryOption(BinaryOption::new(
instrument_id,
raw_symbol,
AssetClass::Alternative,
usdh,
Default::default(),
Default::default(),
4,
2,
Price::from("0.0001"),
Quantity::from("0.01"),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
ts,
ts,
));
client.cache_instrument(&binary);
let with_type = client
.get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
assert!(with_type.is_some());
assert_eq!(with_type.unwrap().id(), instrument_id);
let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
assert!(
no_type.is_some(),
"Outcome coin must resolve through the no-product fallback",
);
assert_eq!(no_type.unwrap().id(), instrument_id);
let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
assert!(missing.is_none());
}
#[rstest]
fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
let clock = get_atomic_clock_realtime();
let ts = clock.get_time_ns();
let canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
Symbol::new("@107"),
hype,
usdc,
5,
2,
Price::from("0.00001"),
Quantity::from("0.01"),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
ts,
ts,
));
let non_canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
Symbol::new("@999"),
hype,
usdc,
5,
2,
Price::from("0.00001"),
Quantity::from("0.01"),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
ts,
ts,
));
client.cache_instrument(&canonical);
client.cache_instrument(&non_canonical);
let instruments_by_coin = client.instruments_by_coin.load();
let by_base = instruments_by_coin
.get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
.expect("base alias must resolve");
assert_eq!(
by_base.raw_symbol().inner().as_str(),
"@107",
"base alias must point to the canonical pair, not the one cached later",
);
}
#[rstest]
fn test_cache_instrument_perp_aliases_sanitized_base() {
let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
let base_currency = Currency::new(
"dex:STREAMABCD****",
8,
0,
"dex:STREAMABCD****",
CurrencyType::Crypto,
);
let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
let clock = get_atomic_clock_realtime();
let ts = clock.get_time_ns();
let hip3 = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
InstrumentId::new(
Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
*HYPERLIQUID_VENUE,
),
Symbol::new("dex:STREAMABCD****"),
base_currency,
usd,
usdc,
false,
6,
3,
Price::from("0.000001"),
Quantity::from("0.001"),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
ts,
ts,
));
client.cache_instrument(&hip3);
let instruments_by_coin = client.instruments_by_coin.load();
let by_raw = instruments_by_coin
.get(&(
Ustr::from("dex:STREAMABCD****"),
HyperliquidProductType::Perp,
))
.expect("venue coin lookup must resolve");
assert_eq!(by_raw.id(), hip3.id());
let by_sanitized = instruments_by_coin
.get(&(
Ustr::from("dex:STREAMABCDxxxx"),
HyperliquidProductType::Perp,
))
.expect("sanitized base lookup must resolve");
assert_eq!(by_sanitized.id(), hip3.id());
drop(instruments_by_coin);
let resolved = client
.get_or_create_instrument(
&Ustr::from("dex:STREAMABCDxxxx"),
Some(HyperliquidProductType::Perp),
)
.expect("get_or_create_instrument must resolve sanitized base for HIP-3");
assert_eq!(resolved.id(), hip3.id());
}
}