Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ SDK for Hyperliquid API trading with Rust.

See `src/bin` for examples. You can run any example with `cargo run --bin [EXAMPLE]`.

### HIP-4 outcomes

The SDK exposes `InfoClient::outcome_meta()` and outcome helpers through
`MarketMetaStore`. Enable outcome metadata explicitly when building a store:

```rust
use hyperliquid_rust_sdk::{MarketMetaStore, MarketMetaStoreOptions};

let store = MarketMetaStore::new(
None,
None,
Some(MarketMetaStoreOptions {
outcomes: true,
..Default::default()
}),
)
.await?;

let yes = store.resolve_outcome_market("#20");
let asset_id = store.outcome_asset_id("#20"); // 100000020
```

Outcome identifiers follow Hyperliquid's HIP-4 asset ID rules:
`encoding = 10 * outcome + side`, coin `#<encoding>`, token `+<encoding>`,
and order/cancel asset ID `100_000_000 + encoding`. Direct limit order,
modify, and cancel helpers can resolve prefixed HIP-4 identifiers such as
`#20` or `+20`, as well as asset IDs such as `100000020`. Bare numeric
encodings like `20` are supported by the outcome helpers but may overlap
existing spot aliases in order conversion. Market-order slippage helpers remain
conservative until Hyperliquid publishes stable outcome size precision.

## Installation

`cargo add hyperliquid_rust_sdk`
Expand Down
177 changes: 139 additions & 38 deletions src/exchange/exchange_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use crate::{
},
helpers::{next_nonce, uuid_to_hex_string},
info::info_client::InfoClient,
market_meta_store::{resolve_outcome_asset_id_from_identifier, MarketMetaStore},
meta::Meta,
prelude::*,
req::HttpClient,
Expand Down Expand Up @@ -110,22 +111,16 @@ impl ExchangeClient {
let client = client.unwrap_or_default();
let base_url = base_url.unwrap_or(BaseUrl::Mainnet);

let info = InfoClient::new(None, Some(base_url)).await?;
let info = InfoClient::new(Some(client.clone()), Some(base_url)).await?;
let meta = if let Some(meta) = meta {
meta
} else {
info.meta().await?
};

let mut coin_to_asset = HashMap::new();
for (asset_ind, asset) in meta.universe.iter().enumerate() {
coin_to_asset.insert(asset.name.clone(), asset_ind as u32);
}

coin_to_asset = info
.spot_meta()
.await?
.add_pair_and_name_to_index_map(coin_to_asset);
let spot_meta = info.spot_meta().await?;
let market_meta_data = MarketMetaStore::build_data(&meta, &spot_meta, &[]);
let coin_to_asset = market_meta_data.coin_to_asset.clone();

Ok(ExchangeClient {
wallet,
Expand All @@ -139,6 +134,18 @@ impl ExchangeClient {
})
}

fn asset_id(&self, coin: &str) -> Result<u32> {
self.coin_to_asset
.get(coin)
.copied()
.or_else(|| resolve_outcome_asset_id_from_identifier(coin))
.ok_or(Error::AssetNotFound)
}

fn conversion_map(&self) -> HashMap<String, u32> {
self.coin_to_asset.clone()
}

async fn post(
&self,
action: serde_json::Value,
Expand Down Expand Up @@ -420,28 +427,28 @@ impl ExchangeClient {
_ => return Err(Error::GenericRequest("Invalid base URL".to_string())),
};
let info_client = InfoClient::new(None, Some(base_url)).await?;
let meta = info_client.meta().await?;

let asset_meta = meta
.universe
.iter()
.find(|a| a.name == asset)
let spot_meta = info_client.spot_meta().await?;
let market_meta_data = MarketMetaStore::build_data(&self.meta, &spot_meta, &[]);
let market_meta_store = MarketMetaStore::from_data(market_meta_data);
let asset_id = market_meta_store
.asset_id(asset)
.or_else(|| self.coin_to_asset.get(asset).copied())
.ok_or(Error::AssetNotFound)?;

let sz_decimals = asset_meta.sz_decimals;
let max_decimals: u32 = if self.coin_to_asset[asset] < 10000 {
6
} else {
8
};
let sz_decimals = market_meta_store
.sz_decimals(asset)
.ok_or(Error::AssetNotFound)?;
let max_decimals: u32 = if asset_id < 10000 { 6 } else { 8 };
let price_decimals = max_decimals.saturating_sub(sz_decimals);

let px = if let Some(px) = px {
px
} else {
let all_mids = info_client.all_mids().await?;
let mid_key = market_meta_store
.mid_key(asset)
.unwrap_or_else(|| asset.to_string());
all_mids
.get(asset)
.get(&mid_key)
.ok_or(Error::AssetNotFound)?
.parse::<f64>()
.map_err(|_| Error::FloatStringParse)?
Expand Down Expand Up @@ -488,10 +495,11 @@ impl ExchangeClient {
let wallet = wallet.unwrap_or(&self.wallet);
let timestamp = next_nonce();

let coin_to_asset = self.conversion_map();
let mut transformed_orders = Vec::new();

for order in orders {
transformed_orders.push(order.convert(&self.coin_to_asset)?);
transformed_orders.push(order.convert(&coin_to_asset)?);
}

let action = Actions::Order(BulkOrder {
Expand All @@ -518,10 +526,11 @@ impl ExchangeClient {

builder.builder = builder.builder.to_lowercase();

let coin_to_asset = self.conversion_map();
let mut transformed_orders = Vec::new();

for order in orders {
transformed_orders.push(order.convert(&self.coin_to_asset)?);
transformed_orders.push(order.convert(&coin_to_asset)?);
}

let action = Actions::Order(BulkOrder {
Expand Down Expand Up @@ -555,10 +564,7 @@ impl ExchangeClient {

let mut transformed_cancels = Vec::new();
for cancel in cancels.into_iter() {
let &asset = self
.coin_to_asset
.get(&cancel.asset)
.ok_or(Error::AssetNotFound)?;
let asset = self.asset_id(&cancel.asset)?;
transformed_cancels.push(CancelRequest {
asset,
oid: cancel.oid,
Expand Down Expand Up @@ -593,11 +599,12 @@ impl ExchangeClient {
let wallet = wallet.unwrap_or(&self.wallet);
let timestamp = next_nonce();

let coin_to_asset = self.conversion_map();
let mut transformed_modifies = Vec::new();
for modify in modifies.into_iter() {
transformed_modifies.push(ModifyRequest {
oid: modify.oid,
order: modify.order.convert(&self.coin_to_asset)?,
order: modify.order.convert(&coin_to_asset)?,
});
}

Expand Down Expand Up @@ -631,10 +638,7 @@ impl ExchangeClient {

let mut transformed_cancels: Vec<CancelRequestCloid> = Vec::new();
for cancel in cancels.into_iter() {
let &asset = self
.coin_to_asset
.get(&cancel.asset)
.ok_or(Error::AssetNotFound)?;
let asset = self.asset_id(&cancel.asset)?;
transformed_cancels.push(CancelRequestCloid {
asset,
cloid: uuid_to_hex_string(cancel.cloid),
Expand Down Expand Up @@ -664,7 +668,7 @@ impl ExchangeClient {

let timestamp = next_nonce();

let &asset_index = self.coin_to_asset.get(coin).ok_or(Error::AssetNotFound)?;
let asset_index = self.asset_id(coin)?;
let action = Actions::UpdateLeverage(UpdateLeverage {
asset: asset_index,
is_cross,
Expand All @@ -689,7 +693,7 @@ impl ExchangeClient {
let amount = (amount * 1_000_000.0).round() as i64;
let timestamp = next_nonce();

let &asset_index = self.coin_to_asset.get(coin).ok_or(Error::AssetNotFound)?;
let asset_index = self.asset_id(coin)?;
let action = Actions::UpdateIsolatedMargin(UpdateIsolatedMargin {
asset: asset_index,
is_buy: true,
Expand Down Expand Up @@ -884,13 +888,14 @@ fn round_to_significant_and_decimal(value: f64, sig_figs: u32, max_decimals: u32

#[cfg(test)]
mod tests {
use std::str::FromStr;
use std::{collections::HashMap, str::FromStr};

use alloy::primitives::address;

use super::*;
use crate::{
exchange::order::{Limit, OrderRequest, Trigger},
exchange::{cancel::ClientCancelRequest, modify::ClientModifyRequest},
Order,
};

Expand Down Expand Up @@ -1164,4 +1169,100 @@ mod tests {

Ok(())
}

fn test_exchange_client(coin_to_asset: HashMap<String, u32>) -> Result<ExchangeClient> {
let wallet = get_wallet()?;
Ok(ExchangeClient {
http_client: HttpClient {
client: Client::new(),
base_url: BaseUrl::Mainnet.get_url(),
},
wallet,
meta: Meta { universe: vec![] },
vault_address: None,
coin_to_asset,
})
}

#[test]
fn exchange_client_conversion_uses_market_meta_store() -> Result<()> {
let coin_to_asset = HashMap::from([
("BTC".to_string(), 0),
("HYPE/USDC".to_string(), 10107),
("@107".to_string(), 10107),
("dexA:AAA".to_string(), 110000),
]);
let client = test_exchange_client(coin_to_asset)?;

let order = ClientOrderRequest {
asset: "HYPE/USDC".to_string(),
is_buy: true,
reduce_only: false,
limit_px: 1.25,
sz: 2.0,
cloid: None,
order_type: ClientOrder::Limit(ClientLimit {
tif: "Gtc".to_string(),
}),
}
.convert(&client.conversion_map())?;
assert_eq!(order.asset, 10107);

let outcome_order = ClientOrderRequest {
asset: "#20".to_string(),
is_buy: true,
reduce_only: false,
limit_px: 0.61,
sz: 1.0,
cloid: None,
order_type: ClientOrder::Limit(ClientLimit {
tif: "Gtc".to_string(),
}),
}
.convert(&client.conversion_map())?;
assert_eq!(outcome_order.asset, 100000020);

let outcome_order_by_asset_id = ClientOrderRequest {
asset: "100000020".to_string(),
is_buy: true,
reduce_only: false,
limit_px: 0.61,
sz: 1.0,
cloid: None,
order_type: ClientOrder::Limit(ClientLimit {
tif: "Gtc".to_string(),
}),
}
.convert(&client.conversion_map())?;
assert_eq!(outcome_order_by_asset_id.asset, 100000020);

let modify = ClientModifyRequest {
oid: 7,
order: ClientOrderRequest {
asset: "dexA:AAA".to_string(),
is_buy: false,
reduce_only: false,
limit_px: 2.0,
sz: 3.0,
cloid: None,
order_type: ClientOrder::Limit(ClientLimit {
tif: "Alo".to_string(),
}),
},
};
assert_eq!(
modify.order.convert(&client.conversion_map())?.asset,
110000
);

let cancel = ClientCancelRequest {
asset: "@107".to_string(),
oid: 42,
};
assert_eq!(client.asset_id(&cancel.asset)?, 10107);
assert_eq!(client.asset_id("#21")?, 100000021);
assert_eq!(client.asset_id("100000021")?, 100000021);

Ok(())
}
}
7 changes: 6 additions & 1 deletion src/exchange/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use uuid::Uuid;
use crate::{
errors::Error,
helpers::{float_to_string_for_hashing, uuid_to_hex_string},
market_meta_store::resolve_outcome_asset_id_from_identifier,
prelude::*,
};

Expand Down Expand Up @@ -109,7 +110,11 @@ impl ClientOrderRequest {
tpsl: trigger.tpsl,
}),
};
let &asset = coin_to_asset.get(&self.asset).ok_or(Error::AssetNotFound)?;
let asset = coin_to_asset
.get(&self.asset)
.copied()
.or_else(|| resolve_outcome_asset_id_from_identifier(&self.asset))
.ok_or(Error::AssetNotFound)?;

let cloid = self.cloid.map(uuid_to_hex_string);

Expand Down
2 changes: 1 addition & 1 deletion src/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ pub fn bps_diff(x: f64, y: f64) -> u16 {
}
}

#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub enum BaseUrl {
Localhost,
Testnet,
Expand Down
Loading