diff --git a/README.md b/README.md index 2e60fea..ea80560 100644 --- a/README.md +++ b/README.md @@ -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 `#`, token `+`, +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` diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index 70b686b..338366a 100644 --- a/src/exchange/exchange_client.rs +++ b/src/exchange/exchange_client.rs @@ -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, @@ -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, @@ -139,6 +134,18 @@ impl ExchangeClient { }) } + fn asset_id(&self, coin: &str) -> Result { + 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 { + self.coin_to_asset.clone() + } + async fn post( &self, action: serde_json::Value, @@ -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::() .map_err(|_| Error::FloatStringParse)? @@ -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 { @@ -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 { @@ -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, @@ -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)?, }); } @@ -631,10 +638,7 @@ impl ExchangeClient { let mut transformed_cancels: Vec = 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), @@ -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, @@ -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, @@ -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, }; @@ -1164,4 +1169,100 @@ mod tests { Ok(()) } + + fn test_exchange_client(coin_to_asset: HashMap) -> Result { + 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(()) + } } diff --git a/src/exchange/order.rs b/src/exchange/order.rs index ef1edb8..50974f2 100644 --- a/src/exchange/order.rs +++ b/src/exchange/order.rs @@ -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::*, }; @@ -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); diff --git a/src/helpers.rs b/src/helpers.rs index c642af7..4799eef 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -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, diff --git a/src/info/info_client.rs b/src/info/info_client.rs index b3d8ba2..51b5cfb 100644 --- a/src/info/info_client.rs +++ b/src/info/info_client.rs @@ -8,10 +8,10 @@ use tokio::sync::mpsc::UnboundedSender; use crate::{ info::{ ActiveAssetDataResponse, CandlesSnapshotResponse, FundingHistoryResponse, - L2SnapshotResponse, OpenOrdersResponse, OrderInfo, RecentTradesResponse, UserFillsResponse, - UserStateResponse, + L2SnapshotResponse, OpenOrdersResponse, OrderInfo, OutcomeMetaResponse, + RecentTradesResponse, UserFillsResponse, UserStateResponse, }, - meta::{AssetContext, Meta, SpotMeta, SpotMetaAndAssetCtxs}, + meta::{AssetContext, Meta, PerpDex, SpotMeta, SpotMetaAndAssetCtxs}, prelude::*, req::HttpClient, ws::{Subscription, WsManager}, @@ -36,10 +36,6 @@ pub enum InfoRequest { UserState { user: Address, }, - #[serde(rename = "batchClearinghouseStates")] - UserStates { - users: Vec
, - }, #[serde(rename = "spotClearinghouseState")] UserTokenBalances { user: Address, @@ -58,6 +54,8 @@ pub enum InfoRequest { MetaAndAssetCtxs, SpotMeta, SpotMetaAndAssetCtxs, + OutcomeMeta, + PerpDexs, AllMids, UserFills { user: Address, @@ -182,6 +180,17 @@ impl InfoClient { serde_json::from_str(&return_data).map_err(|e| Error::JsonParse(e.to_string())) } + async fn send_info_value Deserialize<'a>>( + &self, + info_request: serde_json::Value, + ) -> Result { + let data = + serde_json::to_string(&info_request).map_err(|e| Error::JsonParse(e.to_string()))?; + + let return_data = self.http_client.post("/info", data).await?; + serde_json::from_str(&return_data).map_err(|e| Error::JsonParse(e.to_string())) + } + pub async fn open_orders(&self, address: Address) -> Result> { let input = InfoRequest::OpenOrders { user: address }; self.send_info_request(input).await @@ -192,9 +201,17 @@ impl InfoClient { self.send_info_request(input).await } + /// Fetches clearinghouse state for multiple users. + /// + /// Hyperliquid's public `/info` API does not currently expose a server-side + /// batch clearinghouse endpoint, so this helper calls `clearinghouseState` + /// once per address and returns responses in request order. pub async fn user_states(&self, addresses: Vec
) -> Result> { - let input = InfoRequest::UserStates { users: addresses }; - self.send_info_request(input).await + let mut states = Vec::with_capacity(addresses.len()); + for address in addresses { + states.push(self.user_state(address).await?); + } + Ok(states) } pub async fn user_token_balances(&self, address: Address) -> Result { @@ -212,6 +229,19 @@ impl InfoClient { self.send_info_request(input).await } + pub async fn meta_for_dex(&self, dex: String) -> Result { + self.send_info_value(serde_json::json!({ + "type": "meta", + "dex": dex, + })) + .await + } + + pub async fn perp_dexs(&self) -> Result>> { + let input = InfoRequest::PerpDexs; + self.send_info_request(input).await + } + pub async fn meta_and_asset_contexts(&self) -> Result<(Meta, Vec)> { let input = InfoRequest::MetaAndAssetCtxs; self.send_info_request(input).await @@ -227,6 +257,11 @@ impl InfoClient { self.send_info_request(input).await } + pub async fn outcome_meta(&self) -> Result { + let input = InfoRequest::OutcomeMeta; + self.send_info_request(input).await + } + pub async fn all_mids(&self) -> Result> { let input = InfoRequest::AllMids; self.send_info_request(input).await @@ -321,3 +356,58 @@ impl InfoClient { self.send_info_request(input).await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_perp_dexs_request() { + let json = serde_json::to_value(InfoRequest::PerpDexs).unwrap(); + assert_eq!(json, serde_json::json!({ "type": "perpDexs" })); + } + + #[test] + fn serializes_outcome_meta_request() { + let json = serde_json::to_value(InfoRequest::OutcomeMeta).unwrap(); + assert_eq!(json, serde_json::json!({ "type": "outcomeMeta" })); + } + + #[test] + fn deserializes_outcome_meta_response() { + let payload = serde_json::json!({ + "outcomes": [{ + "outcome": 2, + "name": "Recurring", + "description": "class:priceBinary|underlying:BTC", + "sideSpecs": [ + { "name": "Yes", "token": 20 }, + { "name": "No" } + ] + }], + "questions": [{ + "question": 7, + "name": "BTC daily", + "description": "question", + "fallbackOutcome": 2, + "namedOutcomes": [2], + "settledNamedOutcomes": [] + }] + }); + let meta: OutcomeMetaResponse = serde_json::from_value(payload).unwrap(); + assert_eq!(meta.outcomes[0].outcome, 2); + assert_eq!(meta.outcomes[0].side_specs[0].token, Some(20)); + assert_eq!(meta.outcomes[0].side_specs[1].token, None); + assert_eq!(meta.questions[0].fallback_outcome, 2); + } + + #[test] + fn clearinghouse_state_serializes_single_user_request() { + for user in [Address::ZERO, Address::from([1u8; 20])] { + let json = serde_json::to_value(InfoRequest::UserState { user }).unwrap(); + assert_eq!(json["type"], "clearinghouseState"); + assert_eq!(json["user"], serde_json::to_value(user).unwrap()); + assert!(json.get("users").is_none()); + } + } +} diff --git a/src/info/response_structs.rs b/src/info/response_structs.rs index 221dbf9..be8fe50 100644 --- a/src/info/response_structs.rs +++ b/src/info/response_structs.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use alloy::primitives::Address; @@ -8,7 +8,7 @@ use crate::{ UserTokenBalance, }; -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct UserStateResponse { pub asset_positions: Vec, @@ -17,12 +17,12 @@ pub struct UserStateResponse { pub withdrawable: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] pub struct UserTokenBalanceResponse { pub balances: Vec, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct UserFeesResponse { pub active_referral_discount: String, @@ -32,7 +32,7 @@ pub struct UserFeesResponse { pub user_cross_rate: String, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct OpenOrdersResponse { pub coin: String, @@ -44,7 +44,7 @@ pub struct OpenOrdersResponse { pub cloid: Option, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct UserFillsResponse { pub closed_pnl: String, @@ -64,7 +64,7 @@ pub struct UserFillsResponse { pub twap_id: Option, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct FundingHistoryResponse { pub coin: String, @@ -73,14 +73,14 @@ pub struct FundingHistoryResponse { pub time: u64, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] pub struct UserFundingResponse { pub time: u64, pub hash: String, pub delta: Delta, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct L2SnapshotResponse { pub coin: String, @@ -88,7 +88,7 @@ pub struct L2SnapshotResponse { pub time: u64, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct RecentTradesResponse { pub coin: String, @@ -99,7 +99,7 @@ pub struct RecentTradesResponse { pub hash: String, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] pub struct CandlesSnapshotResponse { #[serde(rename = "t")] pub time_open: u64, @@ -123,7 +123,7 @@ pub struct CandlesSnapshotResponse { pub num_trades: u64, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] pub struct OrderStatusResponse { pub status: String, /// `None` if the order is not found @@ -131,7 +131,7 @@ pub struct OrderStatusResponse { pub order: Option, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct ReferralResponse { pub referred_by: Option, @@ -141,7 +141,7 @@ pub struct ReferralResponse { pub referrer_state: ReferrerState, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct ActiveAssetDataResponse { pub user: Address, @@ -151,3 +151,38 @@ pub struct ActiveAssetDataResponse { pub available_to_trade: Vec, pub mark_px: String, } + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct OutcomeMetaResponse { + pub outcomes: Vec, + #[serde(default)] + pub questions: Vec, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct OutcomeMetaOutcome { + pub outcome: u32, + pub name: String, + pub description: String, + pub side_specs: Vec, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct OutcomeSideSpec { + pub name: String, + pub token: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct OutcomeQuestion { + pub question: u32, + pub name: String, + pub description: String, + pub fallback_outcome: u32, + pub named_outcomes: Vec, + pub settled_named_outcomes: Vec, +} diff --git a/src/info/sub_structs.rs b/src/info/sub_structs.rs index d244a06..bde4416 100644 --- a/src/info/sub_structs.rs +++ b/src/info/sub_structs.rs @@ -10,7 +10,7 @@ pub struct Leverage { pub raw_usd: Option, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct CumulativeFunding { pub all_time: String, @@ -18,7 +18,7 @@ pub struct CumulativeFunding { pub since_change: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct PositionData { pub coin: String, @@ -34,14 +34,14 @@ pub struct PositionData { pub cum_funding: CumulativeFunding, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] pub struct AssetPosition { pub position: PositionData, #[serde(rename = "type")] pub type_string: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct MarginSummary { pub account_value: String, @@ -50,7 +50,7 @@ pub struct MarginSummary { pub total_raw_usd: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Level { pub n: u64, @@ -58,7 +58,7 @@ pub struct Level { pub sz: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Delta { #[serde(rename = "type")] @@ -69,7 +69,7 @@ pub struct Delta { pub funding_rate: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct DailyUserVlm { pub date: String, @@ -78,7 +78,7 @@ pub struct DailyUserVlm { pub user_cross: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct FeeSchedule { pub add: String, @@ -87,20 +87,20 @@ pub struct FeeSchedule { pub tiers: Tiers, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] pub struct Tiers { pub mm: Vec, pub vip: Vec, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Mm { pub add: String, pub maker_fraction_cutoff: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Vip { pub add: String, @@ -108,7 +108,7 @@ pub struct Vip { pub ntl_cutoff: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct UserTokenBalance { pub coin: String, @@ -117,7 +117,7 @@ pub struct UserTokenBalance { pub entry_ntl: String, } -#[derive(Deserialize, Clone, Debug)] +#[derive(Deserialize, Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct OrderInfo { pub order: BasicOrderInfo, @@ -125,7 +125,7 @@ pub struct OrderInfo { pub status_timestamp: u64, } -#[derive(Deserialize, Clone, Debug)] +#[derive(Deserialize, Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct BasicOrderInfo { pub coin: String, @@ -145,21 +145,21 @@ pub struct BasicOrderInfo { pub cloid: Option, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Referrer { pub referrer: Address, pub code: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct ReferrerState { pub stage: String, pub data: ReferrerData, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct ReferrerData { pub required: String, diff --git a/src/lib.rs b/src/lib.rs index 86f20e2..c85a73b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ mod exchange; mod helpers; mod info; mod market_maker; +mod market_meta_store; mod meta; mod prelude; mod req; @@ -18,5 +19,10 @@ pub use exchange::*; pub use helpers::{bps_diff, truncate_float, BaseUrl}; pub use info::{info_client::*, *}; pub use market_maker::{MarketMaker, MarketMakerInput, MarketMakerRestingOrder}; -pub use meta::{AssetContext, AssetMeta, Meta, MetaAndAssetCtxs, SpotAssetMeta, SpotMeta}; +pub use market_meta_store::{ + outcome_encoding_from_identifier, resolve_outcome_asset_id_from_identifier, MarketMetaStore, + MarketMetaStoreData, MarketMetaStoreDexs, MarketMetaStoreOptions, OutcomeMarketRecord, + OutcomeOrderInfo, +}; +pub use meta::{AssetContext, AssetMeta, Meta, MetaAndAssetCtxs, PerpDex, SpotAssetMeta, SpotMeta}; pub use ws::*; diff --git a/src/market_meta_store.rs b/src/market_meta_store.rs new file mode 100644 index 0000000..83e063d --- /dev/null +++ b/src/market_meta_store.rs @@ -0,0 +1,998 @@ +use std::{ + collections::HashMap, + sync::{Arc, RwLock}, + time::Duration, +}; + +use reqwest::Client; +use tokio::task::JoinHandle; + +use crate::{ + info::info_client::InfoClient, + info::{OutcomeMetaResponse, OutcomeQuestion}, + meta::{Meta, PerpDex, SpotMeta}, + prelude::*, + BaseUrl, Error, +}; + +const OUTCOME_ASSET_ID_OFFSET: u32 = 100_000_000; + +#[derive(Debug, Clone, Default)] +pub enum MarketMetaStoreDexs { + #[default] + None, + All, + Selected(Vec), +} + +#[derive(Debug, Clone)] +pub struct MarketMetaStoreOptions { + pub dexs: MarketMetaStoreDexs, + pub outcomes: bool, + pub auto_refresh: bool, + pub refresh_interval: Duration, +} + +impl Default for MarketMetaStoreOptions { + fn default() -> Self { + Self { + dexs: MarketMetaStoreDexs::None, + outcomes: false, + auto_refresh: false, + refresh_interval: Duration::from_secs(60), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutcomeMarketRecord { + pub coin: String, + pub encoding: u32, + pub token_name: String, + pub asset_id: u32, + pub outcome_id: u32, + pub side_index: u32, + pub side_name: String, + pub outcome_name: String, + pub outcome_description: String, + pub question_id: Option, + pub question_name: Option, + pub fallback_outcome: Option, + pub is_settled: bool, + pub mid: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutcomeOrderInfo { + pub coin: String, + pub encoding: u32, + pub token_name: String, + pub asset_id: u32, + pub outcome_id: u32, + pub side_index: u32, + pub side_name: String, + pub outcome_name: String, + pub mid: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct MarketMetaStoreData { + pub coin_to_asset: HashMap, + pub coin_to_sz_decimals: HashMap, + pub spot_pair_to_symbol: HashMap, + pub symbol_to_spot_pair: HashMap, + pub raw_outcome_meta: Option, + pub outcome_markets_by_coin: HashMap, + pub outcome_alias_to_coin: HashMap, + pub outcome_markets_by_outcome: HashMap>, + pub outcome_markets_by_question: HashMap>, + mid_key_by_symbol: HashMap, +} + +#[derive(Debug)] +pub struct MarketMetaStore { + client: Client, + base_url: BaseUrl, + options: MarketMetaStoreOptions, + data: Arc>, + auto_refresh_handle: Option>, +} + +impl MarketMetaStore { + pub async fn new( + client: Option, + base_url: Option, + options: Option, + ) -> Result { + let client = client.unwrap_or_default(); + let base_url = base_url.unwrap_or(BaseUrl::Mainnet); + let options = options.unwrap_or_default(); + let mut store = Self { + client, + base_url, + options, + data: Arc::new(RwLock::new(MarketMetaStoreData::default())), + auto_refresh_handle: None, + }; + store.reload().await?; + if store.options.auto_refresh { + store.start_auto_refresh()?; + } + Ok(store) + } + + pub fn from_data(data: MarketMetaStoreData) -> Self { + Self { + client: Client::new(), + base_url: BaseUrl::Mainnet, + options: MarketMetaStoreOptions::default(), + data: Arc::new(RwLock::new(data)), + auto_refresh_handle: None, + } + } + + pub fn build_data( + meta: &Meta, + spot_meta: &SpotMeta, + builder_metas: &[(usize, String, Meta)], + ) -> MarketMetaStoreData { + Self::build_data_with_outcomes(meta, spot_meta, builder_metas, None, None) + } + + pub fn build_data_with_outcomes( + meta: &Meta, + spot_meta: &SpotMeta, + builder_metas: &[(usize, String, Meta)], + outcome_meta: Option, + all_mids: Option<&HashMap>, + ) -> MarketMetaStoreData { + let mut data = MarketMetaStoreData::default(); + process_default_perps(&mut data, meta); + process_spot_assets(&mut data, spot_meta); + for (perp_dex_index, dex_name, dex_meta) in builder_metas { + process_builder_dex(&mut data, *perp_dex_index, dex_name, dex_meta); + } + if let Some(outcome_meta) = outcome_meta { + process_outcomes(&mut data, outcome_meta, all_mids); + } + data + } + + pub async fn reload(&mut self) -> Result<()> { + let data = fetch_data( + &self.client, + self.base_url, + &self.options.dexs, + self.options.outcomes, + ) + .await?; + self.replace_data(data)?; + Ok(()) + } + + pub async fn refresh_now(&mut self) -> Result<()> { + self.reload().await + } + + pub fn start_auto_refresh(&mut self) -> Result<()> { + if self.options.refresh_interval.is_zero() { + return Err(Error::GenericRequest( + "Refresh interval must be greater than zero".to_string(), + )); + } + self.stop_auto_refresh(); + + let client = self.client.clone(); + let base_url = self.base_url; + let dexs = self.options.dexs.clone(); + let outcomes = self.options.outcomes; + let refresh_interval = self.options.refresh_interval; + let data = Arc::clone(&self.data); + + let handle = tokio::runtime::Handle::try_current().map_err(|e| { + Error::GenericRequest(format!("Tokio runtime required for auto refresh: {e}")) + })?; + + self.auto_refresh_handle = Some(handle.spawn(async move { + let mut interval = tokio::time::interval(refresh_interval); + loop { + interval.tick().await; + if let Ok(next_data) = fetch_data(&client, base_url, &dexs, outcomes).await { + if let Ok(mut guard) = data.write() { + *guard = next_data; + } + } + } + })); + Ok(()) + } + + pub fn stop_auto_refresh(&mut self) { + if let Some(handle) = self.auto_refresh_handle.take() { + handle.abort(); + } + } + + pub fn is_auto_refresh_enabled(&self) -> bool { + self.auto_refresh_handle.is_some() + } + + pub fn replace_data(&mut self, data: MarketMetaStoreData) -> Result<()> { + let mut guard = self + .data + .write() + .map_err(|e| Error::GenericRequest(format!("Market meta store lock poisoned: {e}")))?; + *guard = data; + Ok(()) + } + + pub fn snapshot(&self) -> MarketMetaStoreData { + self.data + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + pub fn coin_to_asset(&self) -> HashMap { + self.snapshot().coin_to_asset + } + + pub fn coin_to_sz_decimals(&self) -> HashMap { + self.snapshot().coin_to_sz_decimals + } + + pub fn asset_id(&self, symbol: &str) -> Option { + self.data.read().ok().and_then(|guard| { + guard + .coin_to_asset + .get(symbol) + .copied() + .or_else(|| resolve_outcome_asset_id_from_identifier(symbol)) + }) + } + + pub fn sz_decimals(&self, symbol: &str) -> Option { + self.data + .read() + .ok() + .and_then(|guard| guard.coin_to_sz_decimals.get(symbol).copied()) + } + + pub fn spot_pair_id(&self, symbol: &str) -> Option { + let normalized = normalize_spot_pair_id(symbol); + self.data.read().ok().and_then(|guard| { + guard + .symbol_to_spot_pair + .get(symbol) + .or_else(|| guard.symbol_to_spot_pair.get(&normalized)) + .cloned() + }) + } + + pub fn symbol_by_spot_pair_id(&self, pair_id: &str) -> Option { + let normalized = normalize_spot_pair_id(pair_id); + self.data + .read() + .ok() + .and_then(|guard| guard.spot_pair_to_symbol.get(&normalized).cloned()) + } + + pub fn mid_key(&self, symbol: &str) -> Option { + let normalized = normalize_spot_pair_id(symbol); + self.data.read().ok().and_then(|guard| { + guard + .mid_key_by_symbol + .get(symbol) + .or_else(|| guard.mid_key_by_symbol.get(&normalized)) + .cloned() + }) + } + + pub fn apply_all_mids(&mut self, mids: &HashMap) -> Result<()> { + let mut guard = self + .data + .write() + .map_err(|e| Error::GenericRequest(format!("Market meta store lock poisoned: {e}")))?; + apply_outcome_mids(&mut guard, mids); + Ok(()) + } + + pub fn resolve_outcome_market(&self, coin_or_id: &str) -> Option { + self.data.read().ok().and_then(|guard| { + let alias = outcome_alias_candidates(coin_or_id) + .into_iter() + .find_map(|candidate| guard.outcome_alias_to_coin.get(&candidate).cloned()); + alias.and_then(|coin| guard.outcome_markets_by_coin.get(&coin).cloned()) + }) + } + + pub fn outcome_markets(&self) -> Vec { + self.data + .read() + .map(|guard| guard.outcome_markets_by_coin.values().cloned().collect()) + .unwrap_or_default() + } + + pub fn outcome_markets_by_outcome(&self, outcome_id: u32) -> Vec { + self.data + .read() + .ok() + .and_then(|guard| guard.outcome_markets_by_outcome.get(&outcome_id).cloned()) + .unwrap_or_default() + } + + pub fn outcome_markets_by_question(&self, question_id: u32) -> Vec { + self.data + .read() + .ok() + .and_then(|guard| guard.outcome_markets_by_question.get(&question_id).cloned()) + .unwrap_or_default() + } + + pub fn outcome_asset_id(&self, coin_or_id: &str) -> Option { + self.resolve_outcome_market(coin_or_id) + .map(|record| record.asset_id) + .or_else(|| resolve_outcome_asset_id_from_identifier(coin_or_id)) + } + + pub fn outcome_token_name(&self, coin_or_id: &str) -> Option { + self.resolve_outcome_market(coin_or_id) + .map(|record| record.token_name) + .or_else(|| { + outcome_encoding_from_identifier(coin_or_id).map(|encoding| format!("+{encoding}")) + }) + } + + pub fn outcome_encoding(&self, coin_or_id: &str) -> Option { + self.resolve_outcome_market(coin_or_id) + .map(|record| record.encoding) + .or_else(|| outcome_encoding_from_identifier(coin_or_id)) + } + + pub fn outcome_order_info(&self, coin_or_id: &str) -> Option { + self.resolve_outcome_market(coin_or_id) + .map(|record| OutcomeOrderInfo { + coin: record.coin, + encoding: record.encoding, + token_name: record.token_name, + asset_id: record.asset_id, + outcome_id: record.outcome_id, + side_index: record.side_index, + side_name: record.side_name, + outcome_name: record.outcome_name, + mid: record.mid, + }) + } +} + +impl Drop for MarketMetaStore { + fn drop(&mut self) { + self.stop_auto_refresh(); + } +} + +async fn fetch_data( + client: &Client, + base_url: BaseUrl, + dexs: &MarketMetaStoreDexs, + outcomes: bool, +) -> Result { + let info = InfoClient::new(Some(client.clone()), Some(base_url)).await?; + let meta = info.meta().await?; + let spot_meta = info.spot_meta().await?; + let builder_metas = fetch_builder_metas(&info, dexs).await?; + let outcome_meta = if outcomes { + Some(info.outcome_meta().await?) + } else { + None + }; + Ok(MarketMetaStore::build_data_with_outcomes( + &meta, + &spot_meta, + &builder_metas, + outcome_meta, + None, + )) +} + +async fn fetch_builder_metas( + info: &InfoClient, + dexs: &MarketMetaStoreDexs, +) -> Result> { + match dexs { + MarketMetaStoreDexs::None => Ok(Vec::new()), + MarketMetaStoreDexs::All | MarketMetaStoreDexs::Selected(_) => { + let perp_dexs = info.perp_dexs().await?; + let selected = match dexs { + MarketMetaStoreDexs::Selected(names) => Some(names), + _ => None, + }; + + let mut out = Vec::new(); + for (index, dex) in perp_dexs.into_iter().enumerate() { + if index == 0 { + continue; + } + let Some(PerpDex { name }) = dex else { + continue; + }; + if name.trim().is_empty() { + continue; + } + if let Some(selected) = selected { + if !selected.iter().any(|candidate| candidate == &name) { + continue; + } + } + let dex_meta = info.meta_for_dex(name.clone()).await?; + out.push((index, name, dex_meta)); + } + Ok(out) + } + } +} + +fn process_default_perps(data: &mut MarketMetaStoreData, meta: &Meta) { + for (index, asset) in meta.universe.iter().enumerate() { + let asset_id = index as u32; + set_aliases( + data, + &[ + asset.name.clone(), + format!("{}-PERP", asset.name), + format!("main:{}", asset.name), + format!("main:{}-PERP", asset.name), + ], + asset_id, + asset.sz_decimals, + &asset.name, + false, + ); + } +} + +fn process_spot_assets(data: &mut MarketMetaStoreData, spot_meta: &SpotMeta) { + let token_map: HashMap = spot_meta + .tokens + .iter() + .map(|token| (token.index, (token.name.as_str(), token.sz_decimals as u32))) + .collect(); + + for market in spot_meta.universe.iter() { + let Some((base_name, sz_decimals)) = token_map.get(&market.tokens[0]) else { + continue; + }; + let Some((quote_name, _)) = token_map.get(&market.tokens[1]) else { + continue; + }; + + let asset_id = 10000 + market.index as u32; + let symbol = format!("{base_name}/{quote_name}"); + let spot_pair_id = normalize_spot_pair_id(if market.name.trim().is_empty() { + market.index.to_string() + } else { + market.name.clone() + }); + let canonical_pair_id = format!("@{}", market.index); + let numeric_pair_id = market.index.to_string(); + let asset_id_alias = asset_id.to_string(); + + let mut aliases = vec![ + symbol.clone(), + spot_pair_id.clone(), + canonical_pair_id.clone(), + numeric_pair_id.clone(), + asset_id_alias.clone(), + ]; + if *quote_name == "USDC" { + aliases.push(format!("{base_name}-SPOT")); + } + + set_aliases(data, &aliases, asset_id, *sz_decimals, &spot_pair_id, true); + for alias in unique_names(aliases) { + data.symbol_to_spot_pair.insert(alias, spot_pair_id.clone()); + } + data.spot_pair_to_symbol + .insert(spot_pair_id.clone(), symbol.clone()); + data.spot_pair_to_symbol + .insert(canonical_pair_id.clone(), symbol.clone()); + data.spot_pair_to_symbol.insert(numeric_pair_id, symbol); + } +} + +fn process_builder_dex( + data: &mut MarketMetaStoreData, + perp_dex_index: usize, + dex_name: &str, + dex_meta: &Meta, +) { + let normalized_dex = dex_name.trim(); + if normalized_dex.is_empty() { + return; + } + let offset = 100000 + (perp_dex_index as u32) * 10000; + + for (index, asset) in dex_meta.universe.iter().enumerate() { + let asset_id = offset + index as u32; + let coin = asset + .name + .strip_prefix(&format!("{normalized_dex}:")) + .unwrap_or(&asset.name); + let qualified_name = format!("{normalized_dex}:{coin}"); + + set_aliases( + data, + &[ + qualified_name.clone(), + format!("{qualified_name}-PERP"), + asset.name.clone(), + format!("{}-PERP", asset.name), + coin.to_string(), + format!("{coin}-PERP"), + ], + asset_id, + asset.sz_decimals, + &qualified_name, + true, + ); + } +} + +fn process_outcomes( + data: &mut MarketMetaStoreData, + outcome_meta: OutcomeMetaResponse, + all_mids: Option<&HashMap>, +) { + let questions = outcome_meta.questions.clone(); + data.raw_outcome_meta = Some(outcome_meta.clone()); + + for outcome in outcome_meta.outcomes { + for (side_index, side) in outcome.side_specs.iter().enumerate() { + if side_index > 1 { + continue; + } + let side_index = side_index as u32; + let encoding = outcome + .outcome + .saturating_mul(10) + .saturating_add(side_index); + let Some(asset_id) = outcome_asset_id_from_encoding(encoding) else { + continue; + }; + + let coin = format!("#{encoding}"); + let token_name = format!("+{encoding}"); + let question = question_for_outcome(&questions, outcome.outcome); + let is_settled = question + .map(|question| question.settled_named_outcomes.contains(&outcome.outcome)) + .unwrap_or(false); + let mid = all_mids.and_then(|mids| mids.get(&coin).cloned()); + + let record = OutcomeMarketRecord { + coin: coin.clone(), + encoding, + token_name: token_name.clone(), + asset_id, + outcome_id: outcome.outcome, + side_index, + side_name: side.name.clone(), + outcome_name: outcome.name.clone(), + outcome_description: outcome.description.clone(), + question_id: question.map(|question| question.question), + question_name: question.map(|question| question.name.clone()), + fallback_outcome: question.map(|question| question.fallback_outcome), + is_settled, + mid, + }; + + data.coin_to_asset.insert(coin.clone(), asset_id); + data.coin_to_asset.insert(token_name.clone(), asset_id); + data.coin_to_asset.insert(asset_id.to_string(), asset_id); + for alias in outcome_alias_candidates(&coin) { + data.outcome_alias_to_coin.insert(alias, coin.clone()); + } + data.outcome_markets_by_coin.insert(coin, record); + } + } + + rebuild_outcome_indexes(data); +} + +fn question_for_outcome( + questions: &[OutcomeQuestion], + outcome_id: u32, +) -> Option<&OutcomeQuestion> { + questions.iter().find(|question| { + question.fallback_outcome == outcome_id + || question.named_outcomes.contains(&outcome_id) + || question.settled_named_outcomes.contains(&outcome_id) + }) +} + +fn apply_outcome_mids(data: &mut MarketMetaStoreData, mids: &HashMap) { + for (coin, mid) in mids { + let Some(alias) = outcome_alias_candidates(coin) + .into_iter() + .find_map(|candidate| data.outcome_alias_to_coin.get(&candidate).cloned()) + else { + continue; + }; + if let Some(record) = data.outcome_markets_by_coin.get_mut(&alias) { + record.mid = Some(mid.clone()); + } + } + rebuild_outcome_indexes(data); +} + +fn rebuild_outcome_indexes(data: &mut MarketMetaStoreData) { + data.outcome_markets_by_outcome.clear(); + data.outcome_markets_by_question.clear(); + + let mut records: Vec<_> = data.outcome_markets_by_coin.values().cloned().collect(); + records.sort_by_key(|record| record.encoding); + + for record in records { + data.outcome_markets_by_outcome + .entry(record.outcome_id) + .or_default() + .push(record.clone()); + if let Some(question_id) = record.question_id { + data.outcome_markets_by_question + .entry(question_id) + .or_default() + .push(record); + } + } +} + +fn outcome_asset_id_from_encoding(encoding: u32) -> Option { + let side = encoding % 10; + let outcome = encoding / 10; + if outcome == 0 || side > 1 { + return None; + } + OUTCOME_ASSET_ID_OFFSET.checked_add(encoding) +} + +pub fn outcome_encoding_from_identifier(value: &str) -> Option { + let text = value.trim(); + if text.is_empty() { + return None; + } + + let numeric = text + .strip_prefix('#') + .or_else(|| text.strip_prefix('+')) + .unwrap_or(text); + if !numeric.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + + let value = numeric.parse::().ok()?; + let encoding = if value >= OUTCOME_ASSET_ID_OFFSET { + value.checked_sub(OUTCOME_ASSET_ID_OFFSET)? + } else { + value + }; + outcome_asset_id_from_encoding(encoding)?; + Some(encoding) +} + +pub fn resolve_outcome_asset_id_from_identifier(value: &str) -> Option { + outcome_encoding_from_identifier(value).and_then(outcome_asset_id_from_encoding) +} + +fn outcome_alias_candidates(value: &str) -> Vec { + let text = value.trim(); + let Some(encoding) = outcome_encoding_from_identifier(text) else { + return if text.is_empty() { + Vec::new() + } else { + vec![text.to_string()] + }; + }; + let asset_id = outcome_asset_id_from_encoding(encoding).unwrap_or_default(); + unique_names([ + text.to_string(), + format!("#{encoding}"), + format!("+{encoding}"), + encoding.to_string(), + asset_id.to_string(), + ]) +} + +fn set_aliases( + data: &mut MarketMetaStoreData, + aliases: &[String], + asset_id: u32, + sz_decimals: u32, + mid_key: &str, + preserve_existing: bool, +) { + for alias in unique_names(aliases.iter().map(String::as_str)) { + if preserve_existing && data.coin_to_asset.contains_key(&alias) { + continue; + } + data.coin_to_asset.insert(alias.clone(), asset_id); + data.coin_to_sz_decimals.insert(alias.clone(), sz_decimals); + data.mid_key_by_symbol.insert(alias, mid_key.to_string()); + } +} + +fn normalize_spot_pair_id(value: impl ToString) -> String { + let text = value.to_string().trim().to_string(); + if text.chars().all(|ch| ch.is_ascii_digit()) && !text.is_empty() { + format!("@{text}") + } else { + text + } +} + +fn unique_names(values: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + let mut out = Vec::new(); + for value in values { + let text = value.as_ref().trim(); + if text.is_empty() || out.iter().any(|existing| existing == text) { + continue; + } + out.push(text.to_string()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::info::{OutcomeMetaOutcome, OutcomeSideSpec}; + use crate::meta::{AssetMeta, SpotAssetMeta, TokenInfo}; + use alloy::primitives::B128; + + fn meta(names: &[(&str, u32)]) -> Meta { + Meta { + universe: names + .iter() + .map(|(name, sz_decimals)| AssetMeta { + name: (*name).to_string(), + sz_decimals: *sz_decimals, + max_leverage: 50, + only_isolated: None, + }) + .collect(), + } + } + + fn spot_meta() -> SpotMeta { + SpotMeta { + tokens: vec![ + TokenInfo { + name: "PURR".to_string(), + sz_decimals: 0, + wei_decimals: 6, + index: 0, + token_id: B128::ZERO, + is_canonical: true, + }, + TokenInfo { + name: "USDC".to_string(), + sz_decimals: 6, + wei_decimals: 6, + index: 1, + token_id: B128::ZERO, + is_canonical: true, + }, + TokenInfo { + name: "HYPE".to_string(), + sz_decimals: 2, + wei_decimals: 8, + index: 2, + token_id: B128::ZERO, + is_canonical: true, + }, + ], + universe: vec![ + SpotAssetMeta { + tokens: [0, 1], + name: "PURR/USDC".to_string(), + index: 0, + is_canonical: true, + }, + SpotAssetMeta { + tokens: [2, 1], + name: "@107".to_string(), + index: 107, + is_canonical: true, + }, + ], + } + } + + fn outcome_meta() -> OutcomeMetaResponse { + OutcomeMetaResponse { + outcomes: vec![ + OutcomeMetaOutcome { + outcome: 2, + name: "Recurring".to_string(), + description: "class:priceBinary|underlying:BTC".to_string(), + side_specs: vec![ + OutcomeSideSpec { + name: "Yes".to_string(), + token: Some(20), + }, + OutcomeSideSpec { + name: "No".to_string(), + token: None, + }, + ], + }, + OutcomeMetaOutcome { + outcome: 3, + name: "Settled".to_string(), + description: "class:binary".to_string(), + side_specs: vec![ + OutcomeSideSpec { + name: "Yes".to_string(), + token: None, + }, + OutcomeSideSpec { + name: "No".to_string(), + token: None, + }, + ], + }, + ], + questions: vec![ + OutcomeQuestion { + question: 7, + name: "BTC daily".to_string(), + description: "question".to_string(), + fallback_outcome: 2, + named_outcomes: vec![2], + settled_named_outcomes: vec![], + }, + OutcomeQuestion { + question: 8, + name: "Settled question".to_string(), + description: "question".to_string(), + fallback_outcome: 3, + named_outcomes: vec![], + settled_named_outcomes: vec![3], + }, + ], + } + } + + #[test] + fn maps_perp_spot_and_builder_aliases() { + let data = MarketMetaStore::build_data( + &meta(&[("BTC", 5), ("ETH", 4)]), + &spot_meta(), + &[ + (1, "dexA".to_string(), meta(&[("dexA:AAA", 1)])), + (2, "dexB".to_string(), meta(&[("BBB", 2)])), + (3, "dexC".to_string(), meta(&[("BTC", 0)])), + ], + ); + + assert_eq!(data.coin_to_asset.get("BTC"), Some(&0)); + assert_eq!(data.coin_to_asset.get("BTC-PERP"), Some(&0)); + assert_eq!(data.coin_to_asset.get("main:BTC"), Some(&0)); + assert_eq!(data.coin_to_asset.get("main:BTC-PERP"), Some(&0)); + assert_eq!(data.coin_to_sz_decimals.get("ETH"), Some(&4)); + + assert_eq!(data.coin_to_asset.get("PURR/USDC"), Some(&10000)); + assert_eq!(data.coin_to_asset.get("HYPE/USDC"), Some(&10107)); + assert_eq!(data.coin_to_asset.get("HYPE-SPOT"), Some(&10107)); + assert_eq!(data.coin_to_asset.get("@107"), Some(&10107)); + assert_eq!(data.coin_to_asset.get("107"), Some(&10107)); + assert_eq!(data.coin_to_asset.get("10107"), Some(&10107)); + assert_eq!(data.coin_to_sz_decimals.get("@107"), Some(&2)); + assert_eq!( + data.symbol_to_spot_pair.get("HYPE/USDC"), + Some(&"@107".to_string()) + ); + assert_eq!( + data.spot_pair_to_symbol.get("@107"), + Some(&"HYPE/USDC".to_string()) + ); + + assert_eq!(data.coin_to_asset.get("dexA:AAA"), Some(&110000)); + assert_eq!(data.coin_to_asset.get("dexA:AAA-PERP"), Some(&110000)); + assert_eq!(data.coin_to_asset.get("dexB:BBB"), Some(&120000)); + assert_eq!(data.coin_to_asset.get("BBB"), Some(&120000)); + assert_eq!(data.coin_to_asset.get("dexC:BTC"), Some(&130000)); + assert_eq!( + data.coin_to_asset.get("BTC"), + Some(&0), + "builder DEX bare aliases must not override main perps" + ); + } + + #[test] + fn lookups_normalize_spot_pair_ids_and_replace_atomically() { + let mut store = MarketMetaStore::from_data(MarketMetaStore::build_data( + &meta(&[("BTC", 5)]), + &spot_meta(), + &[], + )); + + assert_eq!(store.asset_id("HYPE-SPOT"), Some(10107)); + assert_eq!(store.sz_decimals("107"), Some(2)); + assert_eq!(store.spot_pair_id("107"), Some("@107".to_string())); + assert_eq!( + store.symbol_by_spot_pair_id("107"), + Some("HYPE/USDC".to_string()) + ); + assert_eq!(store.mid_key("HYPE/USDC"), Some("@107".to_string())); + + let mut next = MarketMetaStoreData::default(); + next.coin_to_asset.insert("NEW".to_string(), 7); + store.replace_data(next).unwrap(); + assert_eq!(store.asset_id("BTC"), None); + assert_eq!(store.asset_id("NEW"), Some(7)); + } + + #[test] + fn maps_hip4_outcome_markets_and_overlays_mids() { + let mids = HashMap::from([ + ("#20".to_string(), "0.604185".to_string()), + ("#21".to_string(), "0.395815".to_string()), + ]); + let data = MarketMetaStore::build_data_with_outcomes( + &meta(&[("BTC", 5)]), + &spot_meta(), + &[], + Some(outcome_meta()), + Some(&mids), + ); + let mut store = MarketMetaStore::from_data(data); + + assert_eq!(store.outcome_asset_id("#20"), Some(100000020)); + assert_eq!(store.outcome_asset_id("+20"), Some(100000020)); + assert_eq!(store.outcome_asset_id("20"), Some(100000020)); + assert_eq!(store.outcome_asset_id("100000020"), Some(100000020)); + assert_eq!(store.outcome_token_name("#20"), Some("+20".to_string())); + assert_eq!(store.outcome_encoding("100000021"), Some(21)); + + let yes = store.resolve_outcome_market("#20").unwrap(); + assert_eq!(yes.coin, "#20"); + assert_eq!(yes.encoding, 20); + assert_eq!(yes.asset_id, 100000020); + assert_eq!(yes.outcome_id, 2); + assert_eq!(yes.side_index, 0); + assert_eq!(yes.side_name, "Yes"); + assert_eq!(yes.question_id, Some(7)); + assert_eq!(yes.mid, Some("0.604185".to_string())); + + let no = store.resolve_outcome_market("+21").unwrap(); + assert_eq!(no.side_name, "No"); + assert_eq!(no.asset_id, 100000021); + assert_eq!(store.outcome_markets_by_outcome(2).len(), 2); + assert_eq!(store.outcome_markets_by_question(7).len(), 2); + assert!(store.resolve_outcome_market("#30").unwrap().is_settled); + + store + .apply_all_mids(&HashMap::from([("#20".to_string(), "0.61".to_string())])) + .unwrap(); + assert_eq!( + store.outcome_order_info("#20").unwrap().mid, + Some("0.61".to_string()) + ); + } + + #[test] + fn parses_hip4_outcome_identifiers() { + assert_eq!(outcome_encoding_from_identifier("#20"), Some(20)); + assert_eq!(outcome_encoding_from_identifier("+21"), Some(21)); + assert_eq!(outcome_encoding_from_identifier("100000020"), Some(20)); + assert_eq!( + resolve_outcome_asset_id_from_identifier("21"), + Some(100000021) + ); + assert_eq!(resolve_outcome_asset_id_from_identifier("#22"), None); + assert_eq!(resolve_outcome_asset_id_from_identifier("#9"), None); + } +} diff --git a/src/meta.rs b/src/meta.rs index 5c983df..41541cd 100644 --- a/src/meta.rs +++ b/src/meta.rs @@ -1,14 +1,20 @@ use std::collections::HashMap; use alloy::primitives::B128; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct Meta { pub universe: Vec, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PerpDex { + pub name: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct SpotMeta { pub universe: Vec, pub tokens: Vec, @@ -45,21 +51,21 @@ impl SpotMeta { } } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[serde(untagged)] pub enum SpotMetaAndAssetCtxs { SpotMeta(SpotMeta), Context(Vec), } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[serde(untagged)] pub enum MetaAndAssetCtxs { Meta(Meta), Context(Vec), } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct SpotAssetContext { pub day_ntl_vlm: String, @@ -70,7 +76,7 @@ pub struct SpotAssetContext { pub coin: String, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct AssetContext { pub day_ntl_vlm: String, @@ -84,7 +90,7 @@ pub struct AssetContext { pub prev_day_px: String, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct AssetMeta { pub name: String, @@ -94,7 +100,7 @@ pub struct AssetMeta { pub only_isolated: Option, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct SpotAssetMeta { pub tokens: [usize; 2], @@ -103,7 +109,7 @@ pub struct SpotAssetMeta { pub is_canonical: bool, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct TokenInfo { pub name: String, diff --git a/tests/batch_clearinghouse_states.rs b/tests/batch_clearinghouse_states.rs new file mode 100644 index 0000000..fb3b756 --- /dev/null +++ b/tests/batch_clearinghouse_states.rs @@ -0,0 +1,48 @@ +//! Integration test: user_states() fetches clearinghouseState for multiple users. +//! +//! Hyperliquid's public /info API currently returns null for a +//! batchClearinghouseStates request, so user_states() is intentionally a +//! client-side helper over the supported clearinghouseState request. +//! +//! Run with: +//! HYPERLIQUID_TEST_LIVE=1 HYPERLIQUID_TEST_USERS=0x...,0x... cargo test test_user_states_live --test batch_clearinghouse_states -- --ignored + +use alloy::primitives::Address; +use hyperliquid_rust_sdk::{BaseUrl, InfoClient}; + +#[tokio::test] +#[ignore = "hits live API; run with HYPERLIQUID_TEST_LIVE=1 cargo test --test batch_clearinghouse_states -- --ignored"] +async fn test_user_states_live() { + if std::env::var("HYPERLIQUID_TEST_LIVE").ok().as_deref() != Some("1") { + eprintln!("Skipping live test (set HYPERLIQUID_TEST_LIVE=1 to run)"); + return; + } + let users = std::env::var("HYPERLIQUID_TEST_USERS") + .expect("set HYPERLIQUID_TEST_USERS to a comma-separated list of 0x addresses"); + let addrs: Vec
= users + .split(',') + .map(|user| user.trim().parse().expect("valid address")) + .collect(); + assert!(!addrs.is_empty(), "provide at least one test user"); + + let client = InfoClient::new(None, Some(BaseUrl::Mainnet)) + .await + .expect("InfoClient::new"); + let states = client + .user_states(addrs.clone()) + .await + .expect("user_states"); + assert_eq!( + states.len(), + addrs.len(), + "user_states returns one clearinghouse state per user in request order" + ); + for (i, state) in states.iter().enumerate() { + let account_value = state + .margin_summary + .account_value + .parse::() + .expect("account_value should parse as f64"); + assert!(account_value >= 0.0, "user {} has valid margin_summary", i); + } +}