use rust_decimal::Decimal;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{
AssetId, Environment, ExchangeId, ExchangeInstance, InstrumentId, InstrumentKind, MarketIndex,
};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "exchange")]
pub enum Market {
#[serde(rename = "hyperliquid")]
Hyperliquid(HyperliquidMarket),
}
impl Market {
pub fn instrument_id(&self) -> InstrumentId {
match self {
Market::Hyperliquid(hl) => hl.instrument_id(),
}
}
pub fn market_index(&self) -> MarketIndex {
match self {
Market::Hyperliquid(hl) => hl.market_index(),
}
}
pub fn is_spot(&self) -> bool {
match self {
Market::Hyperliquid(hl) => hl.is_spot(),
}
}
pub fn is_spot_like(&self) -> bool {
match self {
Market::Hyperliquid(hl) => hl.is_spot_like(),
}
}
pub fn uses_margin(&self) -> bool {
!self.is_spot_like()
}
pub fn base(&self) -> &str {
match self {
Market::Hyperliquid(hl) => hl.base(),
}
}
pub fn quote(&self) -> &str {
match self {
Market::Hyperliquid(hl) => hl.quote(),
}
}
pub fn base_asset(&self) -> AssetId {
AssetId::new(self.base())
}
pub fn quote_asset(&self) -> AssetId {
AssetId::new(self.quote())
}
pub fn instrument_kind(&self) -> InstrumentKind {
match self {
Market::Hyperliquid(hl) => match hl {
HyperliquidMarket::Outcome { .. } => InstrumentKind::Outcome,
HyperliquidMarket::Spot { .. } => InstrumentKind::Spot,
_ => InstrumentKind::Perp,
},
}
}
pub fn is_outcome(&self) -> bool {
match self {
Market::Hyperliquid(hl) => hl.is_outcome(),
}
}
pub fn outcome_params(&self) -> Option<(u32, u8, String)> {
match self {
Market::Hyperliquid(HyperliquidMarket::Outcome {
outcome_id,
side,
name,
..
}) => Some((*outcome_id, *side, name.clone())),
_ => None,
}
}
pub fn exchange_instance(&self, environment: Environment) -> ExchangeInstance {
match self {
Market::Hyperliquid(_) => {
ExchangeInstance::new(ExchangeId::new("hyperliquid"), environment)
}
}
}
pub fn effective_asset_id(&self) -> u32 {
match self {
Market::Hyperliquid(hl) => hl.effective_asset_id(),
}
}
pub fn spot_coin(&self) -> Option<String> {
match self {
Market::Hyperliquid(hl) => hl.spot_coin(),
}
}
pub fn spot_market_index(&self) -> Option<u32> {
match self {
Market::Hyperliquid(hl) => hl.spot_market_index(),
}
}
pub fn hip3_config(&self) -> Option<Hip3MarketConfig> {
match self {
Market::Hyperliquid(hl) => hl.hip3_config(),
}
}
pub fn instrument_meta(&self) -> Option<&InstrumentMetaConfig> {
match self {
Market::Hyperliquid(hl) => hl.instrument_meta(),
}
}
pub fn price_bounds(&self) -> Option<(Decimal, Decimal)> {
match self {
Market::Hyperliquid(hl) => hl.price_bounds(),
}
}
pub fn validation_errors(&self) -> Vec<String> {
match self {
Market::Hyperliquid(hl) => hl.validation_errors(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type")]
pub enum HyperliquidMarket {
#[serde(rename = "perp")]
Perp {
base: String,
#[serde(default = "default_usdc")]
quote: String,
index: u32,
#[serde(default)]
instrument_meta: Option<InstrumentMetaConfig>,
},
#[serde(rename = "spot")]
Spot {
base: String,
#[serde(default = "default_usdc")]
quote: String,
index: u32,
#[serde(default)]
instrument_meta: Option<InstrumentMetaConfig>,
},
#[serde(rename = "hip3")]
Hip3 {
base: String,
quote: String,
dex: String,
dex_index: u32,
asset_index: u32,
#[serde(default)]
instrument_meta: Option<InstrumentMetaConfig>,
},
#[serde(rename = "outcome")]
Outcome {
name: String,
outcome_id: u32,
side: u8,
#[serde(default)]
instrument_meta: Option<InstrumentMetaConfig>,
},
}
fn default_usdc() -> String {
"USDC".to_string()
}
impl HyperliquidMarket {
pub fn outcome_encoding(outcome_id: u32, side: u8) -> u32 {
10 * outcome_id + side as u32
}
pub fn instrument_id(&self) -> InstrumentId {
match self {
Self::Perp { base, .. } => InstrumentId::new(format!("{}-PERP", base)),
Self::Spot { base, .. } => InstrumentId::new(format!("{}-SPOT", base)),
Self::Hip3 { dex, base, .. } => InstrumentId::new(format!("{}:{}-PERP", dex, base)),
Self::Outcome {
outcome_id, side, ..
} => {
let encoding = Self::outcome_encoding(*outcome_id, *side);
InstrumentId::new(format!("#{}-OUTCOME", encoding))
}
}
}
pub fn market_index(&self) -> MarketIndex {
match self {
Self::Perp { index, .. } => MarketIndex::new(*index),
Self::Spot { index, .. } => MarketIndex::new(*index),
Self::Hip3 {
dex_index,
asset_index,
..
} => {
MarketIndex::new(self.calculate_hip3_asset_id(*dex_index, *asset_index))
}
Self::Outcome {
outcome_id, side, ..
} => MarketIndex::new(100_000_000 + Self::outcome_encoding(*outcome_id, *side)),
}
}
pub fn is_spot(&self) -> bool {
matches!(self, Self::Spot { .. })
}
pub fn is_spot_like(&self) -> bool {
matches!(self, Self::Spot { .. } | Self::Outcome { .. })
}
pub fn is_outcome(&self) -> bool {
matches!(self, Self::Outcome { .. })
}
pub fn base(&self) -> &str {
match self {
Self::Perp { base, .. } => base,
Self::Spot { base, .. } => base,
Self::Hip3 { base, .. } => base,
Self::Outcome { name, .. } => name,
}
}
pub fn quote(&self) -> &str {
match self {
Self::Perp { quote, .. } => quote,
Self::Spot { quote, .. } => quote,
Self::Hip3 { quote, .. } => quote,
Self::Outcome { .. } => "USDH",
}
}
pub fn effective_asset_id(&self) -> u32 {
match self {
Self::Perp { index, .. } => *index,
Self::Spot { index, .. } => *index,
Self::Hip3 {
dex_index,
asset_index,
..
} => self.calculate_hip3_asset_id(*dex_index, *asset_index),
Self::Outcome {
outcome_id, side, ..
} => 100_000_000 + Self::outcome_encoding(*outcome_id, *side),
}
}
pub fn coin_name(&self) -> Option<String> {
match self {
Self::Outcome {
outcome_id, side, ..
} => {
let encoding = Self::outcome_encoding(*outcome_id, *side);
Some(format!("#{}", encoding))
}
Self::Spot { base, .. } => Some(base.clone()),
_ => None,
}
}
pub fn spot_coin(&self) -> Option<String> {
match self {
Self::Spot { base, .. } => Some(base.clone()),
_ => None,
}
}
pub fn spot_market_index(&self) -> Option<u32> {
match self {
Self::Spot { index, .. } => Some(*index),
_ => None,
}
}
pub fn hip3_config(&self) -> Option<Hip3MarketConfig> {
match self {
Self::Hip3 {
dex,
dex_index,
quote,
asset_index,
..
} => Some(Hip3MarketConfig {
dex_name: dex.clone(),
dex_index: *dex_index,
quote_currency: quote.clone(),
asset_index: *asset_index,
}),
_ => None,
}
}
pub fn uses_alternate_collateral(&self) -> bool {
match self {
Self::Hip3 { quote, .. } => quote.to_uppercase() != "USDC",
_ => false,
}
}
pub fn dex_name(&self) -> Option<&str> {
match self {
Self::Hip3 { dex, .. } => Some(dex.as_str()),
_ => None,
}
}
fn calculate_hip3_asset_id(&self, dex_index: u32, asset_index: u32) -> u32 {
110_000 + (dex_index.saturating_sub(1) * 10_000) + asset_index
}
pub fn instrument_meta(&self) -> Option<&InstrumentMetaConfig> {
match self {
Self::Perp {
instrument_meta, ..
} => instrument_meta.as_ref(),
Self::Spot {
instrument_meta, ..
} => instrument_meta.as_ref(),
Self::Hip3 {
instrument_meta, ..
} => instrument_meta.as_ref(),
Self::Outcome {
instrument_meta, ..
} => instrument_meta.as_ref(),
}
}
pub fn price_bounds(&self) -> Option<(Decimal, Decimal)> {
match self {
Self::Outcome { .. } => Some((Decimal::ZERO, Decimal::ONE)),
_ => None,
}
}
pub fn validation_errors(&self) -> Vec<String> {
let mut errors = Vec::new();
match self {
Self::Perp {
base,
quote,
instrument_meta,
..
}
| Self::Spot {
base,
quote,
instrument_meta,
..
} => {
if base.trim().is_empty() {
errors.push("market base must not be empty".to_string());
}
if quote.trim().is_empty() {
errors.push("market quote must not be empty".to_string());
}
validate_instrument_meta(instrument_meta.as_ref(), &mut errors);
}
Self::Hip3 {
base,
quote,
dex,
dex_index,
instrument_meta,
..
} => {
if base.trim().is_empty() {
errors.push("HIP3 market base must not be empty".to_string());
}
if quote.trim().is_empty() {
errors.push("HIP3 market quote must not be empty".to_string());
}
if dex.trim().is_empty() {
errors.push("HIP3 market dex must not be empty".to_string());
}
if *dex_index == 0 {
errors.push("HIP3 dex_index must be >= 1".to_string());
}
validate_instrument_meta(instrument_meta.as_ref(), &mut errors);
}
Self::Outcome {
name,
side,
instrument_meta,
..
} => {
if name.trim().is_empty() {
errors.push("Outcome market name must not be empty".to_string());
}
if *side > 1 {
errors.push("Outcome side must be 0 (Yes) or 1 (No)".to_string());
}
validate_instrument_meta(instrument_meta.as_ref(), &mut errors);
}
}
errors
}
}
fn validate_instrument_meta(meta: Option<&InstrumentMetaConfig>, errors: &mut Vec<String>) {
if let Some(meta) = meta {
if meta.tick_size <= Decimal::ZERO {
errors.push("instrument_meta.tick_size must be > 0".to_string());
}
if meta.lot_size <= Decimal::ZERO {
errors.push("instrument_meta.lot_size must be > 0".to_string());
}
if let Some(min_qty) = meta.min_qty {
if min_qty <= Decimal::ZERO {
errors.push("instrument_meta.min_qty must be > 0".to_string());
}
}
if let Some(min_notional) = meta.min_notional {
if min_notional <= Decimal::ZERO {
errors.push("instrument_meta.min_notional must be > 0".to_string());
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Hip3MarketConfig {
pub dex_name: String,
pub dex_index: u32,
pub quote_currency: String,
pub asset_index: u32,
}
impl Hip3MarketConfig {
pub fn calculate_asset_id(&self) -> u32 {
110_000 + (self.dex_index.saturating_sub(1) * 10_000) + self.asset_index
}
pub fn uses_alternate_collateral(&self) -> bool {
self.quote_currency.to_uppercase() != "USDC"
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct InstrumentMetaConfig {
pub tick_size: Decimal,
pub lot_size: Decimal,
#[serde(default)]
pub min_qty: Option<Decimal>,
#[serde(default)]
pub min_notional: Option<Decimal>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_perp_market_instrument_id() {
let market = HyperliquidMarket::Perp {
base: "BTC".to_string(),
quote: "USDC".to_string(),
index: 0,
instrument_meta: None,
};
assert_eq!(market.instrument_id().as_str(), "BTC-PERP");
assert_eq!(market.market_index().value(), 0);
assert!(!market.is_spot());
assert!(!market.is_outcome());
assert_eq!(market.effective_asset_id(), 0);
}
#[test]
fn test_spot_market_instrument_id() {
let market = HyperliquidMarket::Spot {
base: "HYPE".to_string(),
quote: "USDC".to_string(),
index: 10107,
instrument_meta: None,
};
assert_eq!(market.instrument_id().as_str(), "HYPE-SPOT");
assert_eq!(market.market_index().value(), 10107);
assert!(market.is_spot());
assert!(!market.is_outcome());
assert_eq!(market.spot_coin(), Some("HYPE".to_string()));
assert_eq!(market.spot_market_index(), Some(10107));
}
#[test]
fn test_hip3_market_instrument_id() {
let market = HyperliquidMarket::Hip3 {
base: "BTC".to_string(),
quote: "USDE".to_string(),
dex: "hyna".to_string(),
dex_index: 4,
asset_index: 1,
instrument_meta: None,
};
assert_eq!(market.instrument_id().as_str(), "hyna:BTC-PERP");
assert_eq!(market.effective_asset_id(), 140001);
assert!(!market.is_spot());
assert!(!market.is_outcome());
assert!(market.uses_alternate_collateral());
assert_eq!(market.dex_name(), Some("hyna"));
}
#[test]
fn test_hip3_asset_id_calculation() {
let market = HyperliquidMarket::Hip3 {
base: "TEST".to_string(),
quote: "USDC".to_string(),
dex: "test".to_string(),
dex_index: 1,
asset_index: 0,
instrument_meta: None,
};
assert_eq!(market.effective_asset_id(), 110000);
let market2 = HyperliquidMarket::Hip3 {
base: "TEST".to_string(),
quote: "USDC".to_string(),
dex: "test".to_string(),
dex_index: 4,
asset_index: 1,
instrument_meta: None,
};
assert_eq!(market2.effective_asset_id(), 140001);
}
#[test]
fn test_market_enum_serde() {
let json = r#"{
"exchange": "hyperliquid",
"type": "perp",
"base": "BTC",
"quote": "USDC",
"index": 0
}"#;
let market: Market = serde_json::from_str(json).unwrap();
assert_eq!(market.instrument_id().as_str(), "BTC-PERP");
assert!(!market.is_spot());
assert!(!market.is_outcome());
}
#[test]
fn test_hip3_market_serde() {
let json = r#"{
"exchange": "hyperliquid",
"type": "hip3",
"base": "HFUN",
"quote": "USDC",
"dex": "hypurrfun",
"dex_index": 5,
"asset_index": 0
}"#;
let market: Market = serde_json::from_str(json).unwrap();
assert_eq!(market.instrument_id().as_str(), "hypurrfun:HFUN-PERP");
let hip3 = market.hip3_config().unwrap();
assert_eq!(hip3.dex_name, "hypurrfun");
assert_eq!(hip3.dex_index, 5);
}
#[test]
fn test_outcome_encoding() {
assert_eq!(HyperliquidMarket::outcome_encoding(516, 0), 5160);
assert_eq!(HyperliquidMarket::outcome_encoding(516, 1), 5161);
assert_eq!(HyperliquidMarket::outcome_encoding(9, 0), 90);
assert_eq!(HyperliquidMarket::outcome_encoding(10, 1), 101);
}
#[test]
fn test_outcome_market_instrument_id() {
let market = HyperliquidMarket::Outcome {
name: "BTC > 69070".to_string(),
outcome_id: 516,
side: 0,
instrument_meta: None,
};
assert_eq!(market.instrument_id().as_str(), "#5160-OUTCOME");
}
#[test]
fn test_outcome_market_effective_asset_id() {
let yes = HyperliquidMarket::Outcome {
name: "BTC > 69070".to_string(),
outcome_id: 516,
side: 0,
instrument_meta: None,
};
assert_eq!(yes.effective_asset_id(), 100_005_160);
let no = HyperliquidMarket::Outcome {
name: "BTC > 69070".to_string(),
outcome_id: 516,
side: 1,
instrument_meta: None,
};
assert_eq!(no.effective_asset_id(), 100_005_161);
}
#[test]
fn test_outcome_market_coin_name() {
let market = HyperliquidMarket::Outcome {
name: "BTC > 69070".to_string(),
outcome_id: 516,
side: 0,
instrument_meta: None,
};
assert_eq!(market.coin_name(), Some("#5160".to_string()));
}
#[test]
fn test_outcome_market_properties() {
let market = HyperliquidMarket::Outcome {
name: "HYPE > 200".to_string(),
outcome_id: 686,
side: 1,
instrument_meta: None,
};
assert!(market.is_outcome());
assert!(!market.is_spot());
assert_eq!(market.base(), "HYPE > 200");
assert_eq!(market.quote(), "USDH");
assert_eq!(market.spot_coin(), None);
assert_eq!(market.spot_market_index(), None);
assert_eq!(market.hip3_config(), None);
}
#[test]
fn test_outcome_market_serde() {
let json = r#"{
"exchange": "hyperliquid",
"type": "outcome",
"name": "BTC > 69070",
"outcome_id": 516,
"side": 0
}"#;
let market: Market = serde_json::from_str(json).unwrap();
assert_eq!(market.instrument_id().as_str(), "#5160-OUTCOME");
assert!(market.is_outcome());
assert!(!market.is_spot());
assert_eq!(market.instrument_kind(), InstrumentKind::Outcome);
}
#[test]
fn test_outcome_market_kind() {
let outcome = Market::Hyperliquid(HyperliquidMarket::Outcome {
name: "test".to_string(),
outcome_id: 10,
side: 0,
instrument_meta: None,
});
assert_eq!(outcome.instrument_kind(), InstrumentKind::Outcome);
let perp = Market::Hyperliquid(HyperliquidMarket::Perp {
base: "BTC".to_string(),
quote: "USDC".to_string(),
index: 0,
instrument_meta: None,
});
assert_eq!(perp.instrument_kind(), InstrumentKind::Perp);
let spot = Market::Hyperliquid(HyperliquidMarket::Spot {
base: "HYPE".to_string(),
quote: "USDC".to_string(),
index: 10107,
instrument_meta: None,
});
assert_eq!(spot.instrument_kind(), InstrumentKind::Spot);
}
}