diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index 70b686b..e9d6d0c 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::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,17 @@ impl ExchangeClient { }) } + fn asset_id(&self, coin: &str) -> Result { + self.coin_to_asset + .get(coin) + .copied() + .ok_or(Error::AssetNotFound) + } + + fn conversion_map(&self) -> HashMap { + self.coin_to_asset.clone() + } + async fn post( &self, action: serde_json::Value, @@ -420,28 +426,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 +494,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 +525,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 +563,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 +598,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 +637,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 +667,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 +692,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 +887,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 +1168,70 @@ 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 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); + + Ok(()) + } } 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..c9b3fa8 100644 --- a/src/info/info_client.rs +++ b/src/info/info_client.rs @@ -11,7 +11,7 @@ use crate::{ L2SnapshotResponse, OpenOrdersResponse, OrderInfo, RecentTradesResponse, UserFillsResponse, UserStateResponse, }, - meta::{AssetContext, Meta, SpotMeta, SpotMetaAndAssetCtxs}, + meta::{AssetContext, Meta, PerpDex, SpotMeta, SpotMetaAndAssetCtxs}, prelude::*, req::HttpClient, ws::{Subscription, WsManager}, @@ -58,6 +58,7 @@ pub enum InfoRequest { MetaAndAssetCtxs, SpotMeta, SpotMetaAndAssetCtxs, + PerpDexs, AllMids, UserFills { user: Address, @@ -182,6 +183,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 @@ -212,6 +224,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 @@ -321,3 +346,14 @@ 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" })); + } +} diff --git a/src/lib.rs b/src/lib.rs index 86f20e2..edc12bf 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,8 @@ 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::{ + MarketMetaStore, MarketMetaStoreData, MarketMetaStoreDexs, MarketMetaStoreOptions, +}; +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..89a5c89 --- /dev/null +++ b/src/market_meta_store.rs @@ -0,0 +1,570 @@ +use std::{ + collections::HashMap, + sync::{Arc, RwLock}, + time::Duration, +}; + +use reqwest::Client; +use tokio::task::JoinHandle; + +use crate::{ + info::info_client::InfoClient, + meta::{Meta, PerpDex, SpotMeta}, + prelude::*, + BaseUrl, Error, +}; + +#[derive(Debug, Clone, Default)] +pub enum MarketMetaStoreDexs { + #[default] + None, + All, + Selected(Vec), +} + +#[derive(Debug, Clone)] +pub struct MarketMetaStoreOptions { + pub dexs: MarketMetaStoreDexs, + pub auto_refresh: bool, + pub refresh_interval: Duration, +} + +impl Default for MarketMetaStoreOptions { + fn default() -> Self { + Self { + dexs: MarketMetaStoreDexs::None, + auto_refresh: false, + refresh_interval: Duration::from_secs(60), + } + } +} + +#[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, + 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 { + 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); + } + data + } + + pub async fn reload(&mut self) -> Result<()> { + let data = fetch_data(&self.client, self.base_url, &self.options.dexs).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 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).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()) + } + + 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() + }) + } +} + +impl Drop for MarketMetaStore { + fn drop(&mut self) { + self.stop_auto_refresh(); + } +} + +async fn fetch_data( + client: &Client, + base_url: BaseUrl, + dexs: &MarketMetaStoreDexs, +) -> 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?; + Ok(MarketMetaStore::build_data( + &meta, + &spot_meta, + &builder_metas, + )) +} + +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 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::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, + }, + ], + } + } + + #[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)); + } +} diff --git a/src/meta.rs b/src/meta.rs index 5c983df..bf4e251 100644 --- a/src/meta.rs +++ b/src/meta.rs @@ -8,6 +8,12 @@ pub struct Meta { pub universe: Vec, } +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PerpDex { + pub name: String, +} + #[derive(Deserialize, Debug, Clone)] pub struct SpotMeta { pub universe: Vec,