use async_trait::async_trait;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::core::{
HttpClient, Credentials, ExchangeResult, ExchangeError,
ExchangeId, ExchangeType, AccountType, Symbol,
Price, Ticker, OrderBook, Kline,
ExchangeIdentity, MarketData,
Order, OrderRequest, CancelRequest, CancelScope,
OrderType, OrderSide, TimeInForce, OrderStatus,
Balance, AccountInfo, Position, FundingRate, MarginType,
PlaceOrderResponse, BalanceQuery, PositionQuery, PositionModification,
OrderHistoryFilter, FeeInfo,
AmendRequest, OrderResult,
CancelAllResponse,
UserTrade, UserTradeFilter,
MarketDataCapabilities, TradingCapabilities, AccountCapabilities,
};
use crate::core::traits::{Trading, Account, Positions, AmendOrder, BatchOrders, CancelAll, AccountTransfers, FundingHistory};
use crate::core::types::{ConnectorStats, SymbolInfo, AlgoOrderResponse, TransferRequest, TransferHistoryFilter, TransferResponse, FundingPayment, FundingFilter};
use crate::core::utils::{RuntimeLimiter, RateLimitMonitor, RateLimitPressure};
use crate::core::types::{RateLimitCapabilities, LimitModel, RestLimitPool, WsLimits, OrderbookCapabilities};
use crate::core::utils::PrecisionCache;
use super::{HyperliquidUrls, HyperliquidAuth, HyperliquidParser, HyperliquidEndpoint};
use super::endpoints::InfoType;
use super::auth::{HlOrder, HlOrderType, HlTif, normalize_price};
static HYPERLIQUID_POOLS: &[RestLimitPool] = &[RestLimitPool {
name: "default",
max_budget: 1200,
window_seconds: 60,
is_weight: true,
has_server_headers: false,
server_header: None,
header_reports_used: false,
}];
static HYPERLIQUID_RATE_CAPS: RateLimitCapabilities = RateLimitCapabilities {
model: LimitModel::Weight,
rest_pools: HYPERLIQUID_POOLS,
decaying: None,
endpoint_weights: &[],
ws: WsLimits {
max_connections: Some(10),
max_subs_per_conn: Some(1000),
max_msg_per_sec: None,
max_streams_per_conn: None,
},
};
pub struct HyperliquidConnector {
http: HttpClient,
urls: HyperliquidUrls,
auth: Option<HyperliquidAuth>,
is_testnet: bool,
limiter: Arc<Mutex<RuntimeLimiter>>,
monitor: Arc<Mutex<RateLimitMonitor>>,
precision: PrecisionCache,
}
impl HyperliquidConnector {
pub async fn new(
credentials: Option<Credentials>,
is_testnet: bool,
) -> ExchangeResult<Self> {
let urls = if is_testnet {
HyperliquidUrls::TESTNET
} else {
HyperliquidUrls::MAINNET
};
let auth = credentials
.as_ref()
.map(|c| HyperliquidAuth::new_with_network(c, is_testnet))
.transpose()?;
let http = HttpClient::new(30_000)?;
let limiter = Arc::new(Mutex::new(RuntimeLimiter::from_caps(&HYPERLIQUID_RATE_CAPS)));
let monitor = Arc::new(Mutex::new(RateLimitMonitor::new("HyperLiquid")));
Ok(Self {
http,
urls,
auth,
is_testnet,
limiter,
monitor,
precision: PrecisionCache::new(),
})
}
pub async fn public(is_testnet: bool) -> ExchangeResult<Self> {
Self::new(None, is_testnet).await
}
fn require_auth(&self) -> ExchangeResult<&HyperliquidAuth> {
self.auth.as_ref()
.ok_or_else(|| ExchangeError::Auth(
"Authentication required. Provide wallet credentials.".to_string()
))
}
fn wallet_address(&self) -> ExchangeResult<&str> {
Ok(self.require_auth()?.wallet_address())
}
async fn rate_limit_wait(&self, weight: u32, essential: bool) -> bool {
loop {
let wait_time = {
let mut limiter = self.limiter.lock()
.expect("rate limiter mutex poisoned");
let pressure = self.monitor.lock()
.expect("rate monitor mutex poisoned")
.check(&mut limiter);
if pressure >= RateLimitPressure::Cutoff && !essential {
return false;
}
if limiter.try_acquire("default", weight) {
return true;
}
limiter.time_until_ready("default", weight)
};
if wait_time > Duration::ZERO {
tokio::time::sleep(wait_time).await;
}
}
}
async fn info_request(
&self,
info_type: InfoType,
params: serde_json::Value,
) -> ExchangeResult<serde_json::Value> {
let weight = match info_type {
InfoType::L2Book | InfoType::AllMids => 2,
_ => 20,
};
if !self.rate_limit_wait(weight, false).await {
return Err(ExchangeError::RateLimitExceeded {
retry_after: None,
message: "Rate limit budget >= 90% used; non-essential info request dropped".to_string(),
});
}
let url = format!("{}{}", self.urls.rest_url(), HyperliquidEndpoint::Info.path());
let mut body = serde_json::json!({ "type": info_type.as_str() });
if let Some(obj) = body.as_object_mut() {
if let Some(params_obj) = params.as_object() {
obj.extend(params_obj.clone());
}
}
self.http.post(&url, &body, &std::collections::HashMap::new()).await
}
async fn exchange_request(
&self,
body: &serde_json::Value,
) -> ExchangeResult<serde_json::Value> {
self.rate_limit_wait(20, true).await;
let url = format!("{}{}", self.urls.rest_url(), HyperliquidEndpoint::Exchange.path());
let headers = self.require_auth()?.get_headers();
let response = self.http.post(&url, body, &headers).await?;
HyperliquidParser::check_exchange_response(&response)?;
Ok(response)
}
pub async fn get_metadata(&self) -> ExchangeResult<serde_json::Value> {
self.info_request(InfoType::Meta, serde_json::json!({"dex": ""})).await
}
pub async fn get_spot_metadata(&self) -> ExchangeResult<serde_json::Value> {
self.info_request(InfoType::SpotMeta, serde_json::json!({})).await
}
pub async fn get_all_mids(&self) -> ExchangeResult<serde_json::Value> {
self.info_request(InfoType::AllMids, serde_json::json!({"dex": ""})).await
}
async fn symbol_to_asset_index(&self, coin: &str) -> ExchangeResult<u32> {
let meta = self.get_metadata().await?;
let universe = meta.get("universe")
.and_then(|u| u.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing universe in metadata".to_string()))?;
for (idx, item) in universe.iter().enumerate() {
if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
if name.eq_ignore_ascii_case(coin) {
return Ok(idx as u32);
}
}
}
Err(ExchangeError::Parse(format!("Symbol '{}' not found in Hyperliquid metadata", coin)))
}
}
fn interval_to_ms(interval: &str) -> i64 {
match interval {
"1m" => 60_000,
"3m" => 180_000,
"5m" => 300_000,
"15m" => 900_000,
"30m" => 1_800_000,
"1h" => 3_600_000,
"2h" => 7_200_000,
"4h" => 14_400_000,
"6h" => 21_600_000,
"8h" => 28_800_000,
"12h" => 43_200_000,
"1d" | "1D" => 86_400_000,
"3d" => 259_200_000,
"1w" => 604_800_000,
_ => 60_000,
}
}
fn build_order_response(
response: &serde_json::Value,
req: &OrderRequest,
) -> ExchangeResult<PlaceOrderResponse> {
let data = HyperliquidParser::extract_exchange_data(response)?;
let statuses = data.get("statuses")
.and_then(|s| s.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing statuses in order response".to_string()))?;
let status = statuses.first()
.ok_or_else(|| ExchangeError::Parse("Empty statuses array".to_string()))?;
if let Some(err) = status.get("error").and_then(|e| e.as_str()) {
return Err(ExchangeError::Api { code: -1, message: err.to_string() });
}
let (order_id, filled_qty, avg_price, order_status) = if let Some(resting) = status.get("resting") {
let oid = resting.get("oid")
.and_then(|v| v.as_i64())
.map(|v| v.to_string())
.unwrap_or_default();
(oid, 0.0, None, OrderStatus::Open)
} else if let Some(filled) = status.get("filled") {
let oid = filled.get("oid")
.and_then(|v| v.as_i64())
.map(|v| v.to_string())
.unwrap_or_default();
let total_sz = filled.get("totalSz")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let avg_px = filled.get("avgPx")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok());
(oid, total_sz, avg_px, OrderStatus::Filled)
} else {
return Err(ExchangeError::Parse("Unknown order status in response".to_string()));
};
let order_type_for_response = match &req.order_type {
OrderType::Market => OrderType::Market,
OrderType::Limit { price } => OrderType::Limit { price: *price },
OrderType::StopMarket { stop_price } => OrderType::StopMarket { stop_price: *stop_price },
OrderType::StopLimit { stop_price, limit_price } => OrderType::StopLimit {
stop_price: *stop_price,
limit_price: *limit_price,
},
OrderType::PostOnly { price } => OrderType::PostOnly { price: *price },
OrderType::Ioc { price } => OrderType::Ioc { price: *price },
OrderType::Fok { price } => OrderType::Fok { price: *price },
OrderType::ReduceOnly { price } => OrderType::ReduceOnly { price: *price },
other => other.clone(),
};
let price = match &req.order_type {
OrderType::Limit { price } | OrderType::PostOnly { price } |
OrderType::Fok { price } => Some(*price),
OrderType::Ioc { price } => *price,
OrderType::ReduceOnly { price } => *price,
_ => None,
};
let stop_price = match &req.order_type {
OrderType::StopMarket { stop_price } => Some(*stop_price),
OrderType::StopLimit { stop_price, .. } => Some(*stop_price),
_ => None,
};
let order = Order {
id: order_id,
client_order_id: req.client_order_id.clone(),
symbol: req.symbol.base.clone(),
side: req.side,
order_type: order_type_for_response,
status: order_status,
price,
stop_price,
quantity: req.quantity,
filled_quantity: filled_qty,
average_price: avg_price,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: req.time_in_force,
};
Ok(PlaceOrderResponse::Simple(order))
}
fn build_hl_order(req: &OrderRequest, asset_index: u32) -> ExchangeResult<HlOrder> {
let is_buy = matches!(req.side, OrderSide::Buy);
let reduce_only = req.reduce_only || matches!(req.order_type, OrderType::ReduceOnly { .. });
let (price_str, order_type, reduce_only) = match &req.order_type {
OrderType::Market => {
let price = if is_buy { 999_999_999.0f64 } else { 0.000001f64 };
(
normalize_price(price),
HlOrderType::Limit { tif: HlTif::Ioc },
reduce_only,
)
}
OrderType::Limit { price } => {
(normalize_price(*price), HlOrderType::Limit { tif: HlTif::Gtc }, reduce_only)
}
OrderType::PostOnly { price } => {
(normalize_price(*price), HlOrderType::Limit { tif: HlTif::Alo }, reduce_only)
}
OrderType::Ioc { price } => {
let p = price.unwrap_or(if is_buy { 999_999_999.0 } else { 0.000001 });
(normalize_price(p), HlOrderType::Limit { tif: HlTif::Ioc }, reduce_only)
}
OrderType::Fok { price } => {
(normalize_price(*price), HlOrderType::Limit { tif: HlTif::Fok }, reduce_only)
}
OrderType::ReduceOnly { price } => {
let p = price.unwrap_or(if is_buy { 999_999_999.0 } else { 0.000001 });
let tif = if price.is_none() { HlTif::Ioc } else { HlTif::Gtc };
(normalize_price(p), HlOrderType::Limit { tif }, true)
}
OrderType::StopMarket { stop_price } => {
(
normalize_price(*stop_price),
HlOrderType::Trigger {
trigger_px: normalize_price(*stop_price),
is_market: true,
tpsl: "sl".to_string(),
},
reduce_only,
)
}
OrderType::StopLimit { stop_price, limit_price } => {
(
normalize_price(*limit_price),
HlOrderType::Trigger {
trigger_px: normalize_price(*stop_price),
is_market: false,
tpsl: "sl".to_string(),
},
reduce_only,
)
}
other => {
return Err(ExchangeError::UnsupportedOperation(
format!("Order type {:?} not supported on Hyperliquid", other)
));
}
};
let order_type = match (&order_type, req.time_in_force) {
(HlOrderType::Limit { .. }, TimeInForce::PostOnly) => HlOrderType::Limit { tif: HlTif::Alo },
(HlOrderType::Limit { .. }, TimeInForce::Ioc) => HlOrderType::Limit { tif: HlTif::Ioc },
(HlOrderType::Limit { .. }, TimeInForce::Fok) => HlOrderType::Limit { tif: HlTif::Fok },
_ => order_type,
};
Ok(HlOrder {
a: asset_index,
b: is_buy,
p: price_str,
s: normalize_price(req.quantity),
r: reduce_only,
t: order_type,
c: req.client_order_id.clone(),
})
}
impl ExchangeIdentity for HyperliquidConnector {
fn exchange_id(&self) -> ExchangeId {
ExchangeId::HyperLiquid
}
fn metrics(&self) -> ConnectorStats {
let (http_requests, http_errors, last_latency_ms) = self.http.stats();
let (rate_used, rate_max) = if let Ok(mut limiter) = self.limiter.lock() {
limiter.primary_stats()
} else {
(0, 0)
};
ConnectorStats {
http_requests,
http_errors,
last_latency_ms,
rate_used,
rate_max,
rate_groups: Vec::new(),
ws_ping_rtt_ms: 0,
}
}
fn is_testnet(&self) -> bool {
self.is_testnet
}
fn supported_account_types(&self) -> Vec<AccountType> {
vec![
AccountType::Spot,
AccountType::FuturesCross,
]
}
fn exchange_type(&self) -> ExchangeType {
ExchangeType::Dex
}
fn rate_limit_capabilities(&self) -> RateLimitCapabilities {
HYPERLIQUID_RATE_CAPS
}
fn orderbook_capabilities(&self, _account_type: AccountType) -> OrderbookCapabilities {
OrderbookCapabilities {
ws_depths: &[],
ws_default_depth: Some(20),
rest_max_depth: Some(20),
rest_depth_values: &[],
supports_snapshot: true,
supports_delta: false,
update_speeds_ms: &[],
default_speed_ms: Some(500),
ws_channels: &[],
checksum: None,
has_sequence: false,
has_prev_sequence: false,
supports_aggregation: true,
aggregation_levels: &["null", "2", "3", "4", "5"],
}
}
}
#[async_trait]
impl MarketData for HyperliquidConnector {
async fn get_price(
&self,
symbol: Symbol,
_account_type: AccountType,
) -> ExchangeResult<Price> {
let response = self.get_all_mids().await?;
HyperliquidParser::parse_price(&response, &symbol.base)
}
async fn get_orderbook(
&self,
symbol: Symbol,
_depth: Option<u16>,
_account_type: AccountType,
) -> ExchangeResult<OrderBook> {
let params = serde_json::json!({
"coin": &symbol.base,
"nSigFigs": null,
"mantissa": null,
});
let response = self.info_request(InfoType::L2Book, params).await?;
HyperliquidParser::parse_orderbook(&response)
}
async fn get_klines(
&self,
symbol: Symbol,
interval: &str,
limit: Option<u16>,
_account_type: AccountType,
end_time: Option<i64>,
) -> ExchangeResult<Vec<Kline>> {
let now = crate::core::timestamp_millis() as i64;
let end_ms = end_time.unwrap_or(now);
let interval_ms = interval_to_ms(interval);
let count = limit.unwrap_or(5000).min(5000) as i64;
let start_time = end_ms - count * interval_ms;
let params = serde_json::json!({
"req": {
"coin": &symbol.base,
"interval": super::endpoints::map_kline_interval(interval),
"startTime": start_time,
"endTime": end_ms,
}
});
let response = self.info_request(InfoType::CandleSnapshot, params).await?;
HyperliquidParser::parse_klines(&response)
}
async fn get_ticker(
&self,
_symbol: Symbol,
_account_type: AccountType,
) -> ExchangeResult<Ticker> {
Err(ExchangeError::NotSupported(
"get_ticker requires symbol-to-index mapping. Use get_all_mids() instead.".to_string()
))
}
async fn ping(&self) -> ExchangeResult<()> {
self.get_all_mids().await?;
Ok(())
}
async fn get_exchange_info(&self, account_type: AccountType) -> ExchangeResult<Vec<SymbolInfo>> {
let info = match account_type {
AccountType::Spot => {
let response = self.get_spot_metadata().await?;
HyperliquidParser::parse_spot_exchange_info(&response, account_type)?
}
_ => {
let response = self.get_metadata().await?;
HyperliquidParser::parse_perp_exchange_info(&response, account_type)?
}
};
self.precision.load_from_symbols(&info);
Ok(info)
}
fn market_data_capabilities(&self, _account_type: AccountType) -> MarketDataCapabilities {
MarketDataCapabilities {
has_ping: true,
has_price: true,
has_ticker: false,
has_orderbook: true,
has_klines: true,
has_exchange_info: true,
has_recent_trades: false,
has_ws_klines: true, has_ws_trades: true, has_ws_orderbook: true, has_ws_ticker: true, supported_intervals: &[
"1m", "3m", "5m", "15m", "30m",
"1h", "2h", "4h", "8h", "12h",
"1d", "3d", "1w", "1M",
],
max_kline_limit: Some(5000),
}
}
}
#[async_trait]
impl Trading for HyperliquidConnector {
async fn place_order(&self, req: OrderRequest) -> ExchangeResult<PlaceOrderResponse> {
let auth = self.require_auth()?;
let asset_index = self.symbol_to_asset_index(&req.symbol.base).await?;
if let OrderType::Twap { duration_seconds, .. } = req.order_type {
let is_buy = matches!(req.side, OrderSide::Buy);
let size_str = normalize_price(req.quantity);
let body = auth.sign_twap_action(
asset_index,
is_buy,
&size_str,
req.reduce_only,
duration_seconds,
None,
)?;
let response = self.exchange_request(&body).await?;
let algo_id = response
.pointer("/response/data/running/twapId")
.or_else(|| response.pointer("/response/data/twapId"))
.and_then(|v| v.as_u64())
.map(|id| id.to_string())
.unwrap_or_else(|| "0".to_string());
return Ok(PlaceOrderResponse::Algo(AlgoOrderResponse {
algo_id,
status: "Running".to_string(),
executed_count: None,
total_count: None,
}));
}
let hl_order = build_hl_order(&req, asset_index)?;
let body = auth.sign_order_action(&[hl_order], "na", None)?;
let response = self.exchange_request(&body).await?;
build_order_response(&response, &req)
}
async fn cancel_order(&self, req: CancelRequest) -> ExchangeResult<Order> {
let auth = self.require_auth()?;
let cancels: Vec<(u32, u64)> = match &req.scope {
CancelScope::Single { order_id } => {
let oid = order_id.parse::<u64>()
.map_err(|_| ExchangeError::Parse(
format!("Invalid order ID '{}': must be numeric", order_id)
))?;
let asset = if let Some(sym) = &req.symbol {
self.symbol_to_asset_index(&sym.base).await?
} else {
0
};
vec![(asset, oid)]
}
CancelScope::Batch { order_ids } => {
let asset = if let Some(sym) = &req.symbol {
self.symbol_to_asset_index(&sym.base).await?
} else {
0
};
let mut pairs = Vec::with_capacity(order_ids.len());
for oid_str in order_ids {
let oid = oid_str.parse::<u64>()
.map_err(|_| ExchangeError::Parse(
format!("Invalid order ID '{}': must be numeric", oid_str)
))?;
pairs.push((asset, oid));
}
pairs
}
CancelScope::All { symbol: sym_opt } => {
let wallet = self.wallet_address()?;
let params = serde_json::json!({ "user": wallet, "dex": "" });
let response = self.info_request(InfoType::OpenOrders, params).await?;
let orders = HyperliquidParser::parse_orders(&response)?;
let mut pairs = Vec::new();
for o in &orders {
if let Some(ref s) = sym_opt {
if !o.symbol.eq_ignore_ascii_case(&s.base) {
continue;
}
}
if let Ok(oid) = o.id.parse::<u64>() {
let asset = self.symbol_to_asset_index(&o.symbol).await.unwrap_or(0);
pairs.push((asset, oid));
}
}
if pairs.is_empty() {
let sym_str = sym_opt.as_ref().map(|s| s.base.clone()).unwrap_or_default();
return Ok(Order {
id: "0".to_string(),
client_order_id: None,
symbol: sym_str,
side: OrderSide::Buy,
order_type: OrderType::Limit { price: 0.0 },
status: OrderStatus::Canceled,
price: None,
stop_price: None,
quantity: 0.0,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: 0,
updated_at: None,
time_in_force: TimeInForce::Gtc,
});
}
pairs
}
CancelScope::ByLabel(_)
| CancelScope::ByCurrencyKind { .. }
| CancelScope::ScheduledAt(_) => {
return Err(ExchangeError::UnsupportedOperation(
"Hyperliquid does not support this cancel scope".to_string()
));
}
CancelScope::BySymbol { symbol: sym } => {
let wallet = self.wallet_address()?;
let params = serde_json::json!({ "user": wallet, "dex": "" });
let response = self.info_request(InfoType::OpenOrders, params).await?;
let orders = HyperliquidParser::parse_orders(&response)?;
let mut pairs = Vec::new();
for o in &orders {
if !o.symbol.eq_ignore_ascii_case(&sym.base) {
continue;
}
if let Ok(oid) = o.id.parse::<u64>() {
let asset = self.symbol_to_asset_index(&o.symbol).await.unwrap_or(0);
pairs.push((asset, oid));
}
}
if pairs.is_empty() {
return Ok(Order {
id: "0".to_string(),
client_order_id: None,
symbol: sym.base.clone(),
side: OrderSide::Buy,
order_type: OrderType::Limit { price: 0.0 },
status: OrderStatus::Canceled,
price: None,
stop_price: None,
quantity: 0.0,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: 0,
updated_at: None,
time_in_force: TimeInForce::Gtc,
});
}
pairs
}
};
if cancels.is_empty() {
return Err(ExchangeError::Parse("No orders to cancel".to_string()));
}
let body = auth.sign_cancel_action(&cancels, None)?;
let response = self.exchange_request(&body).await?;
let data = HyperliquidParser::extract_exchange_data(&response)?;
let statuses = data.get("statuses")
.and_then(|s| s.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing statuses in cancel response".to_string()))?;
if let Some(first) = statuses.first() {
if let Some(err) = first.get("error").and_then(|e| e.as_str()) {
return Err(ExchangeError::Api { code: -1, message: err.to_string() });
}
}
let symbol = req.symbol.map(|s| s.base).unwrap_or_default();
let first_oid = cancels.first().map(|(_, oid)| oid.to_string()).unwrap_or_default();
Ok(Order {
id: first_oid,
client_order_id: None,
symbol,
side: OrderSide::Buy, order_type: OrderType::Limit { price: 0.0 },
status: OrderStatus::Canceled,
price: None,
stop_price: None,
quantity: 0.0,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: 0,
updated_at: None,
time_in_force: TimeInForce::Gtc,
})
}
async fn get_order(
&self,
_symbol: &str,
order_id: &str,
_account_type: AccountType,
) -> ExchangeResult<Order> {
let wallet = self.wallet_address()?;
let oid_value = if let Ok(oid) = order_id.parse::<u64>() {
serde_json::json!(oid)
} else if order_id.starts_with("0x") {
serde_json::json!({ "cloid": order_id })
} else {
serde_json::json!(order_id.parse::<u64>().map_err(|_|
ExchangeError::Parse(format!("Invalid order ID: {}", order_id))
)?)
};
let params = serde_json::json!({
"user": wallet,
"oid": oid_value,
});
let response = self.info_request(InfoType::OrderStatus, params).await?;
HyperliquidParser::parse_order_status(&response)
}
async fn get_open_orders(
&self,
symbol: Option<&str>,
_account_type: AccountType,
) -> ExchangeResult<Vec<Order>> {
let wallet = self.wallet_address()?;
let params = serde_json::json!({ "user": wallet, "dex": "" });
let response = self.info_request(InfoType::OpenOrders, params).await?;
let mut orders = HyperliquidParser::parse_orders(&response)?;
if let Some(sym) = symbol {
orders.retain(|o| o.symbol.eq_ignore_ascii_case(sym));
}
Ok(orders)
}
async fn get_order_history(
&self,
filter: OrderHistoryFilter,
_account_type: AccountType,
) -> ExchangeResult<Vec<Order>> {
let wallet = self.wallet_address()?;
let response = if filter.start_time.is_some() || filter.end_time.is_some() {
let mut params = serde_json::json!({
"user": wallet,
"aggregateByTime": false,
});
if let Some(start) = filter.start_time {
params["startTime"] = serde_json::json!(start);
}
if let Some(end) = filter.end_time {
params["endTime"] = serde_json::json!(end);
}
self.info_request(InfoType::UserFillsByTime, params).await?
} else {
let params = serde_json::json!({ "user": wallet });
self.info_request(InfoType::HistoricalOrders, params).await?
};
let mut orders = HyperliquidParser::parse_historical_orders(&response)?;
if let Some(sym) = &filter.symbol {
orders.retain(|o| o.symbol.eq_ignore_ascii_case(&sym.base));
}
if let Some(limit) = filter.limit {
orders.truncate(limit as usize);
}
Ok(orders)
}
async fn get_user_trades(
&self,
filter: UserTradeFilter,
_account_type: AccountType,
) -> ExchangeResult<Vec<UserTrade>> {
let wallet = self.wallet_address()?;
let response = if filter.start_time.is_some() || filter.end_time.is_some() {
let mut params = serde_json::json!({
"user": wallet,
"aggregateByTime": false,
});
if let Some(start) = filter.start_time {
params["startTime"] = serde_json::json!(start);
}
if let Some(end) = filter.end_time {
params["endTime"] = serde_json::json!(end);
}
self.info_request(InfoType::UserFillsByTime, params).await?
} else {
let params = serde_json::json!({
"user": wallet,
"aggregateByTime": false,
});
self.info_request(InfoType::UserFills, params).await?
};
let mut trades = HyperliquidParser::parse_user_fills(&response)?;
if let Some(sym) = &filter.symbol {
trades.retain(|t| t.symbol.eq_ignore_ascii_case(sym));
}
if let Some(oid) = &filter.order_id {
trades.retain(|t| &t.order_id == oid);
}
if let Some(limit) = filter.limit {
trades.truncate(limit as usize);
}
Ok(trades)
}
fn trading_capabilities(&self, account_type: AccountType) -> TradingCapabilities {
let is_futures = !matches!(account_type, AccountType::Spot | AccountType::Margin);
TradingCapabilities {
has_market_order: true,
has_limit_order: true,
has_stop_market: is_futures,
has_stop_limit: is_futures,
has_trailing_stop: false,
has_bracket: false,
has_oco: false,
has_amend: is_futures,
has_batch: true,
max_batch_size: Some(10),
has_cancel_all: true,
has_user_trades: true,
has_order_history: true,
}
}
}
#[async_trait]
impl Account for HyperliquidConnector {
async fn get_balance(&self, query: BalanceQuery) -> ExchangeResult<Vec<Balance>> {
let wallet = self.wallet_address()?;
let mut balances = match query.account_type {
AccountType::Spot => {
let params = serde_json::json!({ "user": wallet });
let response = self.info_request(InfoType::SpotClearinghouseState, params).await?;
HyperliquidParser::parse_spot_balances(&response)?
}
_ => {
let params = serde_json::json!({ "user": wallet, "dex": "" });
let response = self.info_request(InfoType::ClearinghouseState, params).await?;
HyperliquidParser::parse_perp_balances(&response)?
}
};
if let Some(ref asset) = query.asset {
balances.retain(|b| b.asset.eq_ignore_ascii_case(asset));
}
Ok(balances)
}
async fn get_account_info(&self, account_type: AccountType) -> ExchangeResult<AccountInfo> {
let wallet = self.wallet_address()?;
let params = serde_json::json!({ "user": wallet, "dex": "" });
let response = self.info_request(InfoType::ClearinghouseState, params).await?;
let balances = HyperliquidParser::parse_perp_balances(&response)?;
let _account_value = response.get("marginSummary")
.and_then(|m| m.get("accountValue"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
Ok(AccountInfo {
account_type,
can_trade: true,
can_withdraw: true,
can_deposit: true,
maker_commission: 0.0002, taker_commission: 0.00035, balances,
})
}
async fn get_fees(&self, symbol: Option<&str>) -> ExchangeResult<FeeInfo> {
if let Ok(wallet) = self.wallet_address() {
let params = serde_json::json!({ "user": wallet });
if let Ok(response) = self.info_request(InfoType::UserFees, params).await {
if let Some(schedule) = response.get("feeSchedule") {
if let Some(tiers) = schedule.get("tiers").and_then(|t| t.as_array()) {
if let Some(first_tier) = tiers.first() {
let maker = first_tier.get("maker")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0002);
let taker = first_tier.get("taker")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.00035);
return Ok(FeeInfo {
maker_rate: maker,
taker_rate: taker,
symbol: symbol.map(String::from),
tier: None,
});
}
}
}
}
}
Ok(FeeInfo {
maker_rate: 0.0002,
taker_rate: 0.00035,
symbol: symbol.map(String::from),
tier: Some("Standard".to_string()),
})
}
fn account_capabilities(&self, account_type: AccountType) -> AccountCapabilities {
let is_futures = !matches!(account_type, AccountType::Spot | AccountType::Margin);
AccountCapabilities {
has_balances: true,
has_account_info: true,
has_fees: true,
has_transfers: true,
has_sub_accounts: false,
has_deposit_withdraw: false,
has_margin: false,
has_earn_staking: false,
has_funding_history: is_futures,
has_ledger: false,
has_convert: false,
has_positions: is_futures,
}
}
}
#[async_trait]
impl Positions for HyperliquidConnector {
async fn get_positions(&self, query: PositionQuery) -> ExchangeResult<Vec<Position>> {
let wallet = self.wallet_address()?;
let params = serde_json::json!({ "user": wallet, "dex": "" });
let response = self.info_request(InfoType::ClearinghouseState, params).await?;
let mut positions = HyperliquidParser::parse_positions(&response)?;
if let Some(ref sym) = query.symbol {
positions.retain(|p| p.symbol.eq_ignore_ascii_case(&sym.base));
}
Ok(positions)
}
async fn get_funding_rate(
&self,
symbol: &str,
_account_type: AccountType,
) -> ExchangeResult<FundingRate> {
let meta_response = self.info_request(
InfoType::MetaAndAssetCtxs,
serde_json::json!({}),
).await?;
HyperliquidParser::parse_funding_rate_for_symbol(&meta_response, symbol)
}
async fn modify_position(&self, req: PositionModification) -> ExchangeResult<()> {
let auth = self.require_auth()?;
match req {
PositionModification::SetLeverage { symbol, leverage, .. } => {
let asset = self.symbol_to_asset_index(&symbol.base).await?;
let body = auth.sign_update_leverage(asset, true, leverage, None)?;
self.exchange_request(&body).await?;
Ok(())
}
PositionModification::SetMarginMode { symbol, margin_type, .. } => {
let asset = self.symbol_to_asset_index(&symbol.base).await?;
let is_cross = matches!(margin_type, MarginType::Cross);
let body = auth.sign_update_leverage(asset, is_cross, 1, None)?;
self.exchange_request(&body).await?;
Ok(())
}
PositionModification::AddMargin { symbol, amount, .. } => {
let asset = self.symbol_to_asset_index(&symbol.base).await?;
let ntli = (amount * 1_000_000.0) as i64;
let body = auth.sign_update_isolated_margin(asset, true, ntli, None)?;
self.exchange_request(&body).await?;
Ok(())
}
PositionModification::RemoveMargin { symbol, amount, .. } => {
let asset = self.symbol_to_asset_index(&symbol.base).await?;
let ntli = -((amount * 1_000_000.0) as i64);
let body = auth.sign_update_isolated_margin(asset, false, ntli, None)?;
self.exchange_request(&body).await?;
Ok(())
}
PositionModification::ClosePosition { symbol, account_type } => {
let wallet = self.wallet_address()?;
let params = serde_json::json!({ "user": wallet, "dex": "" });
let response = self.info_request(InfoType::ClearinghouseState, params).await?;
let positions = HyperliquidParser::parse_positions(&response)?;
let position = positions.iter()
.find(|p| p.symbol.eq_ignore_ascii_case(&symbol.base))
.ok_or_else(|| ExchangeError::Parse(
format!("No open position for symbol '{}'", symbol.base)
))?;
let close_side = match position.side {
crate::core::PositionSide::Long => OrderSide::Sell,
crate::core::PositionSide::Short => OrderSide::Buy,
crate::core::PositionSide::Both => OrderSide::Sell, };
let close_req = OrderRequest {
symbol: symbol.clone(),
side: close_side,
order_type: OrderType::Market,
quantity: position.quantity,
time_in_force: TimeInForce::Ioc,
account_type,
client_order_id: None,
reduce_only: true,
};
self.place_order(close_req).await?;
Ok(())
}
PositionModification::SetTpSl { .. } => {
Err(ExchangeError::UnsupportedOperation(
"SetTpSl is not supported as a standalone operation on Hyperliquid. \
Set TP/SL at order placement using StopMarket/StopLimit order types.".to_string()
))
}
PositionModification::SwitchPositionMode { .. } => Err(ExchangeError::UnsupportedOperation(
"SwitchPositionMode not supported on Hyperliquid".to_string()
)),
PositionModification::MovePositions { .. } => Err(ExchangeError::UnsupportedOperation(
"MovePositions not supported on Hyperliquid".to_string()
)),
}
}
}
#[async_trait]
impl AmendOrder for HyperliquidConnector {
async fn amend_order(&self, req: AmendRequest) -> ExchangeResult<Order> {
let auth = self.require_auth()?;
let oid = req.order_id.parse::<u64>()
.map_err(|_| ExchangeError::Parse(
format!("Invalid Hyperliquid order ID '{}': must be numeric", req.order_id)
))?;
let asset_index = self.symbol_to_asset_index(&req.symbol.base).await?;
let current_order = self.get_order(&req.symbol.base, &req.order_id, req.account_type).await?;
let new_price = req.fields.price.unwrap_or_else(|| {
current_order.price.unwrap_or(0.0)
});
let new_size = req.fields.quantity.unwrap_or(current_order.quantity);
let (price_str, order_type) = match ¤t_order.order_type {
OrderType::PostOnly { .. } => (
normalize_price(new_price),
HlOrderType::Limit { tif: HlTif::Alo },
),
OrderType::Ioc { .. } => (
normalize_price(new_price),
HlOrderType::Limit { tif: HlTif::Ioc },
),
OrderType::Fok { .. } => (
normalize_price(new_price),
HlOrderType::Limit { tif: HlTif::Fok },
),
_ => (
normalize_price(new_price),
HlOrderType::Limit { tif: HlTif::Gtc },
),
};
let is_buy = matches!(current_order.side, OrderSide::Buy);
let hl_order = HlOrder {
a: asset_index,
b: is_buy,
p: price_str,
s: normalize_price(new_size),
r: false,
t: order_type,
c: None,
};
let body = auth.sign_modify_action(oid, &hl_order, None)?;
let response = self.exchange_request(&body).await?;
let data = HyperliquidParser::extract_exchange_data(&response)?;
let statuses = data.get("statuses")
.and_then(|s| s.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing statuses in modify response".to_string()))?;
if let Some(first) = statuses.first() {
if let Some(err) = first.get("error").and_then(|e| e.as_str()) {
return Err(ExchangeError::Api { code: -1, message: err.to_string() });
}
}
Ok(Order {
id: req.order_id,
client_order_id: current_order.client_order_id,
symbol: req.symbol.base.clone(),
side: current_order.side,
order_type: current_order.order_type,
status: OrderStatus::Open,
price: Some(new_price),
stop_price: current_order.stop_price,
quantity: new_size,
filled_quantity: current_order.filled_quantity,
average_price: current_order.average_price,
commission: None,
commission_asset: None,
created_at: current_order.created_at,
updated_at: Some(crate::core::timestamp_millis() as i64),
time_in_force: current_order.time_in_force,
})
}
}
#[async_trait]
impl BatchOrders for HyperliquidConnector {
async fn place_orders_batch(
&self,
orders: Vec<OrderRequest>,
) -> ExchangeResult<Vec<OrderResult>> {
let auth = self.require_auth()?;
let mut hl_orders = Vec::with_capacity(orders.len());
for req in &orders {
let asset_index = self.symbol_to_asset_index(&req.symbol.base).await?;
hl_orders.push(build_hl_order(req, asset_index)?);
}
let body = auth.sign_order_action(&hl_orders, "na", None)?;
let response = self.exchange_request(&body).await?;
let data = HyperliquidParser::extract_exchange_data(&response)?;
let statuses = data.get("statuses")
.and_then(|s| s.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing statuses in batch order response".to_string()))?;
let results: Vec<OrderResult> = statuses.iter().zip(orders.iter()).map(|(status, req)| {
if let Some(err) = status.get("error").and_then(|e| e.as_str()) {
return OrderResult {
order: None,
client_order_id: req.client_order_id.clone(),
success: false,
error: Some(err.to_string()),
error_code: None,
};
}
let (order_id, filled_qty, avg_price, order_status) = if let Some(resting) = status.get("resting") {
let oid = resting.get("oid")
.and_then(|v| v.as_i64())
.map(|v| v.to_string())
.unwrap_or_default();
(oid, 0.0f64, None::<f64>, OrderStatus::Open)
} else if let Some(filled) = status.get("filled") {
let oid = filled.get("oid")
.and_then(|v| v.as_i64())
.map(|v| v.to_string())
.unwrap_or_default();
let total_sz = filled.get("totalSz")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let avg_px = filled.get("avgPx")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok());
(oid, total_sz, avg_px, OrderStatus::Filled)
} else {
return OrderResult {
order: None,
client_order_id: req.client_order_id.clone(),
success: false,
error: Some("Unknown status in batch response".to_string()),
error_code: None,
};
};
let price = match &req.order_type {
OrderType::Limit { price } | OrderType::PostOnly { price } |
OrderType::Fok { price } => Some(*price),
OrderType::Ioc { price } => *price,
OrderType::ReduceOnly { price } => *price,
_ => None,
};
let order = Order {
id: order_id,
client_order_id: req.client_order_id.clone(),
symbol: req.symbol.base.clone(),
side: req.side,
order_type: req.order_type.clone(),
status: order_status,
price,
stop_price: None,
quantity: req.quantity,
filled_quantity: filled_qty,
average_price: avg_price,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: req.time_in_force,
};
OrderResult {
order: Some(order),
client_order_id: req.client_order_id.clone(),
success: true,
error: None,
error_code: None,
}
}).collect();
Ok(results)
}
async fn cancel_orders_batch(
&self,
order_ids: Vec<String>,
symbol: Option<&str>,
_account_type: AccountType,
) -> ExchangeResult<Vec<OrderResult>> {
let auth = self.require_auth()?;
let asset = if let Some(sym) = symbol {
let coin = sym.split('/').next().unwrap_or(sym);
self.symbol_to_asset_index(coin).await.unwrap_or(0)
} else {
0
};
let mut cancels: Vec<(u32, u64)> = Vec::with_capacity(order_ids.len());
for id_str in &order_ids {
let oid = id_str.parse::<u64>()
.map_err(|_| ExchangeError::Parse(
format!("Invalid Hyperliquid order ID '{}': must be numeric", id_str)
))?;
cancels.push((asset, oid));
}
let body = auth.sign_cancel_action(&cancels, None)?;
let response = self.exchange_request(&body).await?;
let data = HyperliquidParser::extract_exchange_data(&response)?;
let statuses = data.get("statuses")
.and_then(|s| s.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing statuses in batch cancel response".to_string()))?;
let results: Vec<OrderResult> = statuses.iter().zip(order_ids.iter()).map(|(status, oid_str)| {
if let Some(err) = status.get("error").and_then(|e| e.as_str()) {
return OrderResult {
order: None,
client_order_id: None,
success: false,
error: Some(err.to_string()),
error_code: None,
};
}
OrderResult {
order: Some(Order {
id: oid_str.clone(),
client_order_id: None,
symbol: symbol.unwrap_or("").to_string(),
side: OrderSide::Buy, order_type: OrderType::Limit { price: 0.0 },
status: OrderStatus::Canceled,
price: None,
stop_price: None,
quantity: 0.0,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: 0,
updated_at: Some(crate::core::timestamp_millis() as i64),
time_in_force: TimeInForce::Gtc,
}),
client_order_id: None,
success: true,
error: None,
error_code: None,
}
}).collect();
Ok(results)
}
fn max_batch_place_size(&self) -> usize {
10 }
fn max_batch_cancel_size(&self) -> usize {
10 }
}
impl HyperliquidConnector {
pub async fn batch_amend_orders(
&self,
modifies: Vec<(String, f64, f64, String)>,
) -> ExchangeResult<serde_json::Value> {
if modifies.is_empty() {
return Ok(serde_json::json!({"status": "ok", "response": {}}));
}
if modifies.len() > 10 {
return Err(ExchangeError::InvalidRequest(
format!("Batch modify size {} exceeds Hyperliquid limit of 10", modifies.len())
));
}
let auth = self.require_auth()?;
let mut hl_modifies: Vec<(u64, HlOrder)> = Vec::with_capacity(modifies.len());
for (oid_str, price, size, symbol) in &modifies {
let oid = oid_str.parse::<u64>()
.map_err(|_| ExchangeError::InvalidRequest(
format!("Invalid Hyperliquid order ID '{}': must be numeric", oid_str)
))?;
let asset_index = self.symbol_to_asset_index(symbol).await?;
hl_modifies.push((oid, HlOrder {
a: asset_index,
b: true, p: normalize_price(*price),
s: normalize_price(*size),
r: false,
t: HlOrderType::Limit { tif: HlTif::Gtc },
c: None,
}));
}
let pairs: Vec<(u64, &HlOrder)> = hl_modifies.iter().map(|(oid, order)| (*oid, order)).collect();
let body = auth.sign_batch_modify_action(&pairs, None)?;
self.exchange_request(&body).await
}
}
#[async_trait]
impl CancelAll for HyperliquidConnector {
async fn cancel_all_orders(
&self,
scope: CancelScope,
_account_type: AccountType,
) -> ExchangeResult<CancelAllResponse> {
let auth = self.require_auth()?;
let wallet = self.wallet_address()?;
let params = serde_json::json!({ "user": wallet, "dex": "" });
let response = self.info_request(InfoType::OpenOrders, params).await?;
let open_orders = HyperliquidParser::parse_orders(&response)?;
let symbol_filter: Option<&str> = match &scope {
CancelScope::All { symbol: Some(sym) } => Some(&sym.base),
CancelScope::BySymbol { symbol: sym } => Some(&sym.base),
CancelScope::All { symbol: None } => None,
_ => {
return Err(ExchangeError::UnsupportedOperation(
"CancelAll::cancel_all_orders only supports All and BySymbol scopes".to_string()
));
}
};
let mut cancels: Vec<(u32, u64)> = Vec::new();
for order in &open_orders {
if let Some(filter) = symbol_filter {
if !order.symbol.eq_ignore_ascii_case(filter) {
continue;
}
}
if let Ok(oid) = order.id.parse::<u64>() {
let asset = self.symbol_to_asset_index(&order.symbol).await.unwrap_or(0);
cancels.push((asset, oid));
}
}
if cancels.is_empty() {
return Ok(CancelAllResponse {
cancelled_count: 0,
failed_count: 0,
details: Vec::new(),
});
}
let body = auth.sign_cancel_action(&cancels, None)?;
let exchange_response = self.exchange_request(&body).await?;
let data = HyperliquidParser::extract_exchange_data(&exchange_response)?;
let statuses = data.get("statuses")
.and_then(|s| s.as_array())
.ok_or_else(|| ExchangeError::Parse(
"Missing statuses in cancel-all response".to_string()
))?;
let mut cancelled_count = 0u32;
let mut failed_count = 0u32;
let mut details: Vec<OrderResult> = Vec::with_capacity(statuses.len());
for (status, (_, oid)) in statuses.iter().zip(cancels.iter()) {
if let Some(err) = status.get("error").and_then(|e| e.as_str()) {
failed_count += 1;
details.push(OrderResult {
order: None,
client_order_id: None,
success: false,
error: Some(err.to_string()),
error_code: None,
});
} else {
cancelled_count += 1;
details.push(OrderResult {
order: Some(Order {
id: oid.to_string(),
client_order_id: None,
symbol: symbol_filter.unwrap_or("").to_string(),
side: OrderSide::Buy,
order_type: OrderType::Limit { price: 0.0 },
status: OrderStatus::Canceled,
price: None,
stop_price: None,
quantity: 0.0,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: 0,
updated_at: Some(crate::core::timestamp_millis() as i64),
time_in_force: TimeInForce::Gtc,
}),
client_order_id: None,
success: true,
error: None,
error_code: None,
});
}
}
Ok(CancelAllResponse {
cancelled_count,
failed_count,
details,
})
}
}
#[async_trait]
impl AccountTransfers for HyperliquidConnector {
async fn transfer(&self, req: TransferRequest) -> ExchangeResult<TransferResponse> {
let auth = self.require_auth()?;
let to_perp = match (&req.from_account, &req.to_account) {
(AccountType::Spot, AccountType::FuturesCross)
| (AccountType::Spot, AccountType::FuturesIsolated) => true,
(AccountType::FuturesCross, AccountType::Spot)
| (AccountType::FuturesIsolated, AccountType::Spot) => false,
_ => {
return Err(ExchangeError::UnsupportedOperation(format!(
"Hyperliquid only supports Spot ↔ Perp (FuturesCross) USDC transfers. \
Got: {:?} → {:?}",
req.from_account, req.to_account
)));
}
};
let amount_str = super::auth::normalize_price(req.amount);
let body = auth.sign_usd_class_transfer(&amount_str, to_perp, None)?;
let response = self.exchange_request(&body).await?;
let transfer_id = response
.pointer("/response/data")
.and_then(|d| d.as_str())
.unwrap_or("ok")
.to_string();
Ok(TransferResponse {
transfer_id,
status: "Successful".to_string(),
asset: req.asset,
amount: req.amount,
timestamp: Some(crate::core::timestamp_millis() as i64),
})
}
async fn get_transfer_history(
&self,
_filter: TransferHistoryFilter,
) -> ExchangeResult<Vec<TransferResponse>> {
Ok(Vec::new())
}
}
#[async_trait]
impl FundingHistory for HyperliquidConnector {
async fn get_funding_payments(
&self,
filter: FundingFilter,
_account_type: AccountType,
) -> ExchangeResult<Vec<FundingPayment>> {
let auth = self.require_auth()?;
let user_addr = auth.wallet_address();
let mut params = serde_json::json!({ "user": user_addr });
if let Some(start) = filter.start_time {
params["startTime"] = serde_json::Value::from(start);
}
if let Some(end) = filter.end_time {
params["endTime"] = serde_json::Value::from(end);
}
let response = self.info_request(InfoType::UserFunding, params).await?;
HyperliquidParser::parse_funding_payments(&response)
}
}