use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CoinKind {
Perp,
Spot,
Unknown,
}
impl CoinKind {
pub fn of(coin: &str) -> Self {
fn is_name(name: &str) -> bool {
!name.is_empty() && name.bytes().all(|byte| byte.is_ascii_alphanumeric())
}
let (kind, valid) = if let Some(index) = coin.strip_prefix('@') {
(
Self::Spot,
!index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()),
)
} else if let Some((base, quote)) = coin.split_once('/') {
(Self::Spot, is_name(base) && is_name(quote))
} else if let Some((deployer, asset)) = coin.split_once(':') {
(Self::Perp, is_name(deployer) && is_name(asset))
} else {
(Self::Perp, is_name(coin))
};
if valid { kind } else { Self::Unknown }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Network {
Mainnet,
Testnet,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(from = "PerpMeta")]
pub struct Perps(HashMap<SmolStr, PerpCoin>);
impl Perps {
pub fn get(&self, name: &str) -> Option<&PerpCoin> {
self.0
.get(name)
.or_else(|| unique(self.0.values(), |perp| perp.coin.eq_ignore_ascii_case(name)))
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &PerpCoin> {
self.0.values()
}
}
impl FromIterator<PerpCoin> for Perps {
fn from_iter<I: IntoIterator<Item = PerpCoin>>(iter: I) -> Self {
Self(
iter.into_iter()
.map(|perp| (perp.coin.clone(), perp))
.collect(),
)
}
}
impl FromIterator<Perps> for Perps {
fn from_iter<I: IntoIterator<Item = Perps>>(iter: I) -> Self {
Self(iter.into_iter().flat_map(|perps| perps.0).collect())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PerpCoin {
coin: SmolStr,
sz_decimals: u32,
delisted: bool,
}
impl PerpCoin {
pub fn coin(&self) -> &str {
&self.coin
}
pub fn deployer(&self) -> Option<&str> {
self.coin.split_once(':').map(|(deployer, _)| deployer)
}
pub fn sz_decimals(&self) -> u32 {
self.sz_decimals
}
pub fn is_delisted(&self) -> bool {
self.delisted
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(from = "SpotMeta")]
pub struct SpotPairs(HashMap<SmolStr, SpotPair>);
impl SpotPairs {
pub fn get(&self, coin: &str) -> Option<&SpotPair> {
self.0.get(coin)
}
pub fn find(&self, base: &str, quote: &str) -> Option<&SpotPair> {
unique(self.0.values(), |pair| {
pair.base == base && pair.quote == quote
})
.or_else(|| {
unique(self.0.values(), |pair| {
pair.base.eq_ignore_ascii_case(base) && pair.quote.eq_ignore_ascii_case(quote)
})
})
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &SpotPair> {
self.0.values()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SpotPair {
coin: SmolStr,
index: u32,
base: SmolStr,
quote: SmolStr,
base_sz_decimals: u32,
}
impl SpotPair {
pub fn coin(&self) -> &str {
&self.coin
}
pub fn index(&self) -> u32 {
self.index
}
pub fn base(&self) -> &str {
&self.base
}
pub fn quote(&self) -> &str {
&self.quote
}
pub fn base_sz_decimals(&self) -> u32 {
self.base_sz_decimals
}
}
fn unique<'a, T>(
items: impl IntoIterator<Item = &'a T>,
matches: impl Fn(&T) -> bool,
) -> Option<&'a T> {
let mut found = items.into_iter().filter(|item| matches(item));
let first = found.next()?;
found.next().is_none().then_some(first)
}
#[derive(Deserialize)]
struct PerpMeta {
universe: Vec<PerpMetaAsset>,
}
#[derive(Deserialize)]
struct PerpMetaAsset {
name: SmolStr,
#[serde(rename = "szDecimals")]
sz_decimals: u32,
#[serde(default, rename = "isDelisted")]
delisted: bool,
}
impl From<PerpMeta> for Perps {
fn from(meta: PerpMeta) -> Self {
meta.universe
.into_iter()
.map(|asset| PerpCoin {
coin: asset.name,
sz_decimals: asset.sz_decimals,
delisted: asset.delisted,
})
.collect()
}
}
#[derive(Deserialize)]
struct SpotMeta {
universe: Vec<SpotMetaPair>,
tokens: Vec<SpotMetaToken>,
}
#[derive(Deserialize)]
struct SpotMetaPair {
name: SmolStr,
index: u32,
tokens: [u32; 2],
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SpotMetaToken {
name: SmolStr,
index: u32,
sz_decimals: u32,
}
impl From<SpotMeta> for SpotPairs {
fn from(meta: SpotMeta) -> Self {
let tokens: HashMap<u32, (SmolStr, u32)> = meta
.tokens
.into_iter()
.map(|token| (token.index, (token.name, token.sz_decimals)))
.collect();
Self(
meta.universe
.into_iter()
.filter_map(|pair| {
let [base, quote] = pair.tokens;
let (base, base_sz_decimals) = tokens.get(&base)?.clone();
let pair = SpotPair {
index: pair.index,
base,
quote: tokens.get("e)?.0.clone(),
coin: pair.name,
base_sz_decimals,
};
Some((pair.coin.clone(), pair))
})
.collect(),
)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
const SPOT_META: &str = r#"{
"universe": [
{"name": "PURR/USDC", "tokens": [1, 0], "index": 0, "isCanonical": true},
{"name": "@107", "tokens": [150, 0], "index": 107, "isCanonical": false},
{"name": "@207", "tokens": [150, 268], "index": 207, "isCanonical": false},
{"name": "@300", "tokens": [999, 0], "index": 300, "isCanonical": false}
],
"tokens": [
{"name": "USDC", "szDecimals": 8, "weiDecimals": 8, "index": 0, "isCanonical": true},
{"name": "PURR", "szDecimals": 0, "weiDecimals": 5, "index": 1, "isCanonical": true},
{"name": "HYPE", "szDecimals": 2, "weiDecimals": 8, "index": 150, "isCanonical": false},
{"name": "USDT0", "szDecimals": 2, "weiDecimals": 8, "index": 268, "isCanonical": false}
]
}"#;
fn pairs() -> SpotPairs {
serde_json::from_str(SPOT_META).unwrap()
}
#[test]
fn spot_pairs_resolve_a_coin_to_its_tokens_by_index() {
let pairs = pairs();
let hype = pairs.get("@107").unwrap();
assert_eq!(
(hype.coin(), hype.index(), hype.base(), hype.quote()),
("@107", 107, "HYPE", "USDC")
);
let hype_usdt0 = pairs.get("@207").unwrap();
assert_eq!((hype_usdt0.base(), hype_usdt0.quote()), ("HYPE", "USDT0"));
let purr = pairs.get("PURR/USDC").unwrap();
assert_eq!(
(purr.index(), purr.base(), purr.quote()),
(0, "PURR", "USDC")
);
}
#[test]
fn a_spot_pair_carries_its_base_tokens_sz_decimals() {
let pairs = pairs();
assert_eq!(pairs.get("@107").unwrap().base_sz_decimals(), 2, "HYPE");
assert_eq!(
pairs.get("@207").unwrap().base_sz_decimals(),
2,
"HYPE, not USDT0"
);
assert_eq!(
pairs.get("PURR/USDC").unwrap().base_sz_decimals(),
0,
"PURR"
);
}
#[test]
fn spot_pairs_leave_out_a_pair_with_an_unlisted_token() {
let pairs = pairs();
assert_eq!(pairs.get("@300"), None);
assert_eq!(pairs.len(), 3);
assert_eq!(pairs.iter().count(), 3);
}
#[test]
fn spot_pairs_match_a_coin_exactly() {
let pairs = pairs();
assert_eq!(pairs.get("purr/usdc"), None);
assert_eq!(pairs.get("@0"), None, "PURR/USDC is named only PURR/USDC");
assert_eq!(pairs.get("@999"), None);
assert!(SpotPairs::default().is_empty());
}
#[test]
fn spot_pairs_find_a_pair_by_its_tokens_ignoring_case() {
let pairs = pairs();
assert_eq!(pairs.find("HYPE", "USDT0").unwrap().coin(), "@207");
assert_eq!(pairs.find("hype", "usdc").unwrap().coin(), "@107");
assert_eq!(pairs.find("Purr", "Usdc").unwrap().coin(), "PURR/USDC");
assert_eq!(
pairs.find("USDC", "HYPE"),
None,
"base and quote are not interchangeable"
);
assert_eq!(pairs.find("HYPE", "USDH"), None);
}
#[test]
fn spot_pairs_find_no_pair_when_ignoring_case_matches_several() {
let pairs: SpotPairs = serde_json::from_str(
r#"{
"universe": [
{"name": "@1", "tokens": [1, 0], "index": 1},
{"name": "@2", "tokens": [2, 0], "index": 2}
],
"tokens": [
{"name": "USDC", "szDecimals": 8, "index": 0},
{"name": "ABC", "szDecimals": 2, "index": 1},
{"name": "abc", "szDecimals": 2, "index": 2}
]
}"#,
)
.unwrap();
assert_eq!(pairs.find("ABC", "USDC").unwrap().coin(), "@1");
assert_eq!(pairs.find("abc", "USDC").unwrap().coin(), "@2");
assert_eq!(pairs.find("Abc", "USDC"), None);
}
const META: &str = r#"{
"universe": [
{"szDecimals": 5, "name": "BTC", "maxLeverage": 40, "marginTableId": 56},
{"szDecimals": 0, "name": "kPEPE", "maxLeverage": 10, "marginTableId": 10},
{"szDecimals": 1, "name": "MATIC", "maxLeverage": 20, "marginTableId": 20,
"isDelisted": true}
],
"marginTables": [],
"collateralToken": 0
}"#;
const META_XYZ: &str = r#"{
"universe": [
{"szDecimals": 3, "name": "xyz:TSLA", "maxLeverage": 20, "marginTableId": 20,
"growthMode": "enabled", "deployerFeeScale": "1.0"}
],
"marginTables": [],
"collateralToken": 0
}"#;
fn perps() -> Perps {
[META, META_XYZ]
.into_iter()
.map(|meta| serde_json::from_str::<Perps>(meta).unwrap())
.collect()
}
#[test]
fn perps_collect_the_default_and_a_deployers_perpetuals() {
let perps = perps();
assert_eq!(perps.len(), 4);
assert_eq!(perps.iter().count(), 4);
let tsla = perps.get("xyz:TSLA").unwrap();
assert_eq!((tsla.coin(), tsla.deployer()), ("xyz:TSLA", Some("xyz")));
let btc = perps.get("BTC").unwrap();
assert_eq!((btc.deployer(), btc.is_delisted()), (None, false));
assert!(perps.get("MATIC").unwrap().is_delisted());
assert!(Perps::default().is_empty());
}
#[test]
fn a_perpetual_carries_its_sz_decimals() {
let perps = perps();
assert_eq!(perps.get("BTC").unwrap().sz_decimals(), 5);
assert_eq!(perps.get("kPEPE").unwrap().sz_decimals(), 0);
assert_eq!(perps.get("xyz:TSLA").unwrap().sz_decimals(), 3);
}
#[test]
fn a_perpetual_without_sz_decimals_is_refused() {
assert!(serde_json::from_str::<Perps>(r#"{"universe": [{"name": "BTC"}]}"#).is_err());
}
#[test]
fn perps_get_returns_the_venue_spelling_whatever_the_case_asked() {
let perps = perps();
for name in ["kPEPE", "KPEPE", "kpepe"] {
assert_eq!(perps.get(name).unwrap().coin(), "kPEPE", "{name:?}");
}
assert_eq!(perps.get("XYZ:tsla").unwrap().coin(), "xyz:TSLA");
assert_eq!(
perps.get("TSLA"),
None,
"a HIP-3 perpetual is named with its deployer"
);
assert_eq!(perps.get("ETH"), None);
}
#[test]
fn perps_get_prefers_an_exact_match_and_refuses_an_ambiguous_one() {
let perps: Perps = serde_json::from_str(
r#"{"universe": [{"name": "ABC", "szDecimals": 0}, {"name": "abc", "szDecimals": 0}]}"#,
)
.unwrap();
assert_eq!(perps.get("ABC").unwrap().coin(), "ABC");
assert_eq!(perps.get("abc").unwrap().coin(), "abc");
assert_eq!(perps.get("Abc"), None);
}
#[test]
fn network_serializes_in_lower_case() {
assert_eq!(
serde_json::to_string(&Network::Mainnet).unwrap(),
r#""mainnet""#
);
assert_eq!(
serde_json::from_str::<Network>(r#""testnet""#).unwrap(),
Network::Testnet
);
}
#[test]
fn coin_kind_judges_a_coin_by_its_shape() {
for (coin, kind) in [
("BTC", CoinKind::Perp),
("kPEPE", CoinKind::Perp),
("xyz:TSLA", CoinKind::Perp),
("xyz:XYZ100", CoinKind::Perp),
("@107", CoinKind::Spot),
("@0", CoinKind::Spot),
("PURR/USDC", CoinKind::Spot),
("#12", CoinKind::Unknown),
("+12", CoinKind::Unknown),
("", CoinKind::Unknown),
("@", CoinKind::Unknown),
("@1x", CoinKind::Unknown),
("PURR/", CoinKind::Unknown),
("/USDC", CoinKind::Unknown),
("A/B/C", CoinKind::Unknown),
(":TSLA", CoinKind::Unknown),
("xyz:", CoinKind::Unknown),
("a:b:c", CoinKind::Unknown),
("BTC-USD", CoinKind::Unknown),
] {
assert_eq!(CoinKind::of(coin), kind, "{coin:?}");
}
}
}