1use rust_decimal::Decimal;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use crate::{
11 AssetId, Environment, ExchangeId, ExchangeInstance, InstrumentId, InstrumentKind, MarketIndex,
12};
13
14#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
23#[serde(tag = "exchange")]
24pub enum Market {
25 #[serde(rename = "hyperliquid")]
27 Hyperliquid(HyperliquidMarket),
28 }
32
33impl Market {
34 pub fn instrument_id(&self) -> InstrumentId {
36 match self {
37 Market::Hyperliquid(hl) => hl.instrument_id(),
38 }
39 }
40
41 pub fn market_index(&self) -> MarketIndex {
43 match self {
44 Market::Hyperliquid(hl) => hl.market_index(),
45 }
46 }
47
48 pub fn is_spot(&self) -> bool {
50 match self {
51 Market::Hyperliquid(hl) => hl.is_spot(),
52 }
53 }
54
55 pub fn is_spot_like(&self) -> bool {
57 match self {
58 Market::Hyperliquid(hl) => hl.is_spot_like(),
59 }
60 }
61
62 pub fn uses_margin(&self) -> bool {
64 !self.is_spot_like()
65 }
66
67 pub fn base(&self) -> &str {
69 match self {
70 Market::Hyperliquid(hl) => hl.base(),
71 }
72 }
73
74 pub fn quote(&self) -> &str {
76 match self {
77 Market::Hyperliquid(hl) => hl.quote(),
78 }
79 }
80
81 pub fn base_asset(&self) -> AssetId {
83 AssetId::new(self.base())
84 }
85
86 pub fn quote_asset(&self) -> AssetId {
88 AssetId::new(self.quote())
89 }
90
91 pub fn instrument_kind(&self) -> InstrumentKind {
93 match self {
94 Market::Hyperliquid(hl) => match hl {
95 HyperliquidMarket::Outcome { .. } => InstrumentKind::Outcome,
96 HyperliquidMarket::Spot { .. } => InstrumentKind::Spot,
97 _ => InstrumentKind::Perp,
98 },
99 }
100 }
101
102 pub fn is_outcome(&self) -> bool {
104 match self {
105 Market::Hyperliquid(hl) => hl.is_outcome(),
106 }
107 }
108
109 pub fn outcome_params(&self) -> Option<(u32, u8, String)> {
111 match self {
112 Market::Hyperliquid(HyperliquidMarket::Outcome {
113 outcome_id,
114 side,
115 name,
116 ..
117 }) => Some((*outcome_id, *side, name.clone())),
118 _ => None,
119 }
120 }
121
122 pub fn exchange_instance(&self, environment: Environment) -> ExchangeInstance {
124 match self {
125 Market::Hyperliquid(_) => {
126 ExchangeInstance::new(ExchangeId::new("hyperliquid"), environment)
127 }
128 }
129 }
130
131 pub fn effective_asset_id(&self) -> u32 {
133 match self {
134 Market::Hyperliquid(hl) => hl.effective_asset_id(),
135 }
136 }
137
138 pub fn spot_coin(&self) -> Option<String> {
140 match self {
141 Market::Hyperliquid(hl) => hl.spot_coin(),
142 }
143 }
144
145 pub fn spot_market_index(&self) -> Option<u32> {
147 match self {
148 Market::Hyperliquid(hl) => hl.spot_market_index(),
149 }
150 }
151
152 pub fn hip3_config(&self) -> Option<Hip3MarketConfig> {
154 match self {
155 Market::Hyperliquid(hl) => hl.hip3_config(),
156 }
157 }
158
159 pub fn instrument_meta(&self) -> Option<&InstrumentMetaConfig> {
161 match self {
162 Market::Hyperliquid(hl) => hl.instrument_meta(),
163 }
164 }
165
166 pub fn price_bounds(&self) -> Option<(Decimal, Decimal)> {
168 match self {
169 Market::Hyperliquid(hl) => hl.price_bounds(),
170 }
171 }
172
173 pub fn validation_errors(&self) -> Vec<String> {
175 match self {
176 Market::Hyperliquid(hl) => hl.validation_errors(),
177 }
178 }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
187#[serde(tag = "type")]
188pub enum HyperliquidMarket {
189 #[serde(rename = "perp")]
191 Perp {
192 base: String,
194 #[serde(default = "default_usdc")]
196 quote: String,
197 index: u32,
199 #[serde(default)]
201 instrument_meta: Option<InstrumentMetaConfig>,
202 },
203
204 #[serde(rename = "spot")]
206 Spot {
207 base: String,
209 #[serde(default = "default_usdc")]
211 quote: String,
212 index: u32,
214 #[serde(default)]
216 instrument_meta: Option<InstrumentMetaConfig>,
217 },
218
219 #[serde(rename = "hip3")]
221 Hip3 {
222 base: String,
224 quote: String,
226 dex: String,
228 dex_index: u32,
230 asset_index: u32,
232 #[serde(default)]
234 instrument_meta: Option<InstrumentMetaConfig>,
235 },
236
237 #[serde(rename = "outcome")]
242 Outcome {
243 name: String,
245 outcome_id: u32,
247 side: u8,
249 #[serde(default)]
251 instrument_meta: Option<InstrumentMetaConfig>,
252 },
253}
254
255fn default_usdc() -> String {
256 "USDC".to_string()
257}
258
259impl HyperliquidMarket {
260 pub fn outcome_encoding(outcome_id: u32, side: u8) -> u32 {
262 10 * outcome_id + side as u32
263 }
264
265 pub fn instrument_id(&self) -> InstrumentId {
267 match self {
268 Self::Perp { base, .. } => InstrumentId::new(format!("{}-PERP", base)),
269 Self::Spot { base, .. } => InstrumentId::new(format!("{}-SPOT", base)),
270 Self::Hip3 { dex, base, .. } => InstrumentId::new(format!("{}:{}-PERP", dex, base)),
271 Self::Outcome {
272 outcome_id, side, ..
273 } => {
274 let encoding = Self::outcome_encoding(*outcome_id, *side);
275 InstrumentId::new(format!("#{}-OUTCOME", encoding))
276 }
277 }
278 }
279
280 pub fn market_index(&self) -> MarketIndex {
282 match self {
283 Self::Perp { index, .. } => MarketIndex::new(*index),
284 Self::Spot { index, .. } => MarketIndex::new(*index),
285 Self::Hip3 {
286 dex_index,
287 asset_index,
288 ..
289 } => {
290 MarketIndex::new(self.calculate_hip3_asset_id(*dex_index, *asset_index))
292 }
293 Self::Outcome {
294 outcome_id, side, ..
295 } => MarketIndex::new(100_000_000 + Self::outcome_encoding(*outcome_id, *side)),
296 }
297 }
298
299 pub fn is_spot(&self) -> bool {
301 matches!(self, Self::Spot { .. })
302 }
303
304 pub fn is_spot_like(&self) -> bool {
306 matches!(self, Self::Spot { .. } | Self::Outcome { .. })
307 }
308
309 pub fn is_outcome(&self) -> bool {
311 matches!(self, Self::Outcome { .. })
312 }
313
314 pub fn base(&self) -> &str {
316 match self {
317 Self::Perp { base, .. } => base,
318 Self::Spot { base, .. } => base,
319 Self::Hip3 { base, .. } => base,
320 Self::Outcome { name, .. } => name,
321 }
322 }
323
324 pub fn quote(&self) -> &str {
326 match self {
327 Self::Perp { quote, .. } => quote,
328 Self::Spot { quote, .. } => quote,
329 Self::Hip3 { quote, .. } => quote,
330 Self::Outcome { .. } => "USDH",
331 }
332 }
333
334 pub fn effective_asset_id(&self) -> u32 {
341 match self {
342 Self::Perp { index, .. } => *index,
343 Self::Spot { index, .. } => *index,
344 Self::Hip3 {
345 dex_index,
346 asset_index,
347 ..
348 } => self.calculate_hip3_asset_id(*dex_index, *asset_index),
349 Self::Outcome {
350 outcome_id, side, ..
351 } => 100_000_000 + Self::outcome_encoding(*outcome_id, *side),
352 }
353 }
354
355 pub fn coin_name(&self) -> Option<String> {
361 match self {
362 Self::Outcome {
363 outcome_id, side, ..
364 } => {
365 let encoding = Self::outcome_encoding(*outcome_id, *side);
366 Some(format!("#{}", encoding))
367 }
368 Self::Spot { base, .. } => Some(base.clone()),
369 _ => None,
370 }
371 }
372
373 pub fn spot_coin(&self) -> Option<String> {
375 match self {
376 Self::Spot { base, .. } => Some(base.clone()),
377 _ => None,
378 }
379 }
380
381 pub fn spot_market_index(&self) -> Option<u32> {
383 match self {
384 Self::Spot { index, .. } => Some(*index),
385 _ => None,
386 }
387 }
388
389 pub fn hip3_config(&self) -> Option<Hip3MarketConfig> {
391 match self {
392 Self::Hip3 {
393 dex,
394 dex_index,
395 quote,
396 asset_index,
397 ..
398 } => Some(Hip3MarketConfig {
399 dex_name: dex.clone(),
400 dex_index: *dex_index,
401 quote_currency: quote.clone(),
402 asset_index: *asset_index,
403 }),
404 _ => None,
405 }
406 }
407
408 pub fn uses_alternate_collateral(&self) -> bool {
410 match self {
411 Self::Hip3 { quote, .. } => quote.to_uppercase() != "USDC",
412 _ => false,
413 }
414 }
415
416 pub fn dex_name(&self) -> Option<&str> {
418 match self {
419 Self::Hip3 { dex, .. } => Some(dex.as_str()),
420 _ => None,
421 }
422 }
423
424 fn calculate_hip3_asset_id(&self, dex_index: u32, asset_index: u32) -> u32 {
426 110_000 + (dex_index.saturating_sub(1) * 10_000) + asset_index
427 }
428
429 pub fn instrument_meta(&self) -> Option<&InstrumentMetaConfig> {
431 match self {
432 Self::Perp {
433 instrument_meta, ..
434 } => instrument_meta.as_ref(),
435 Self::Spot {
436 instrument_meta, ..
437 } => instrument_meta.as_ref(),
438 Self::Hip3 {
439 instrument_meta, ..
440 } => instrument_meta.as_ref(),
441 Self::Outcome {
442 instrument_meta, ..
443 } => instrument_meta.as_ref(),
444 }
445 }
446
447 pub fn price_bounds(&self) -> Option<(Decimal, Decimal)> {
449 match self {
450 Self::Outcome { .. } => Some((Decimal::ZERO, Decimal::ONE)),
451 _ => None,
452 }
453 }
454
455 pub fn validation_errors(&self) -> Vec<String> {
457 let mut errors = Vec::new();
458
459 match self {
460 Self::Perp {
461 base,
462 quote,
463 instrument_meta,
464 ..
465 }
466 | Self::Spot {
467 base,
468 quote,
469 instrument_meta,
470 ..
471 } => {
472 if base.trim().is_empty() {
473 errors.push("market base must not be empty".to_string());
474 }
475 if quote.trim().is_empty() {
476 errors.push("market quote must not be empty".to_string());
477 }
478 validate_instrument_meta(instrument_meta.as_ref(), &mut errors);
479 }
480 Self::Hip3 {
481 base,
482 quote,
483 dex,
484 dex_index,
485 instrument_meta,
486 ..
487 } => {
488 if base.trim().is_empty() {
489 errors.push("HIP3 market base must not be empty".to_string());
490 }
491 if quote.trim().is_empty() {
492 errors.push("HIP3 market quote must not be empty".to_string());
493 }
494 if dex.trim().is_empty() {
495 errors.push("HIP3 market dex must not be empty".to_string());
496 }
497 if *dex_index == 0 {
498 errors.push("HIP3 dex_index must be >= 1".to_string());
499 }
500 validate_instrument_meta(instrument_meta.as_ref(), &mut errors);
501 }
502 Self::Outcome {
503 name,
504 side,
505 instrument_meta,
506 ..
507 } => {
508 if name.trim().is_empty() {
509 errors.push("Outcome market name must not be empty".to_string());
510 }
511 if *side > 1 {
512 errors.push("Outcome side must be 0 (Yes) or 1 (No)".to_string());
513 }
514 validate_instrument_meta(instrument_meta.as_ref(), &mut errors);
515 }
516 }
517
518 errors
519 }
520}
521
522fn validate_instrument_meta(meta: Option<&InstrumentMetaConfig>, errors: &mut Vec<String>) {
523 if let Some(meta) = meta {
524 if meta.tick_size <= Decimal::ZERO {
525 errors.push("instrument_meta.tick_size must be > 0".to_string());
526 }
527 if meta.lot_size <= Decimal::ZERO {
528 errors.push("instrument_meta.lot_size must be > 0".to_string());
529 }
530 if let Some(min_qty) = meta.min_qty {
531 if min_qty <= Decimal::ZERO {
532 errors.push("instrument_meta.min_qty must be > 0".to_string());
533 }
534 }
535 if let Some(min_notional) = meta.min_notional {
536 if min_notional <= Decimal::ZERO {
537 errors.push("instrument_meta.min_notional must be > 0".to_string());
538 }
539 }
540 }
541}
542
543#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
552pub struct Hip3MarketConfig {
553 pub dex_name: String,
555 pub dex_index: u32,
557 pub quote_currency: String,
559 pub asset_index: u32,
561}
562
563impl Hip3MarketConfig {
564 pub fn calculate_asset_id(&self) -> u32 {
566 110_000 + (self.dex_index.saturating_sub(1) * 10_000) + self.asset_index
567 }
568
569 pub fn uses_alternate_collateral(&self) -> bool {
571 self.quote_currency.to_uppercase() != "USDC"
572 }
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
581pub struct InstrumentMetaConfig {
582 pub tick_size: Decimal,
584 pub lot_size: Decimal,
586 #[serde(default)]
588 pub min_qty: Option<Decimal>,
589 #[serde(default)]
591 pub min_notional: Option<Decimal>,
592}
593
594#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
603 fn test_perp_market_instrument_id() {
604 let market = HyperliquidMarket::Perp {
605 base: "BTC".to_string(),
606 quote: "USDC".to_string(),
607 index: 0,
608 instrument_meta: None,
609 };
610 assert_eq!(market.instrument_id().as_str(), "BTC-PERP");
611 assert_eq!(market.market_index().value(), 0);
612 assert!(!market.is_spot());
613 assert!(!market.is_outcome());
614 assert_eq!(market.effective_asset_id(), 0);
615 }
616
617 #[test]
618 fn test_spot_market_instrument_id() {
619 let market = HyperliquidMarket::Spot {
620 base: "HYPE".to_string(),
621 quote: "USDC".to_string(),
622 index: 10107,
623 instrument_meta: None,
624 };
625 assert_eq!(market.instrument_id().as_str(), "HYPE-SPOT");
626 assert_eq!(market.market_index().value(), 10107);
627 assert!(market.is_spot());
628 assert!(!market.is_outcome());
629 assert_eq!(market.spot_coin(), Some("HYPE".to_string()));
630 assert_eq!(market.spot_market_index(), Some(10107));
631 }
632
633 #[test]
634 fn test_hip3_market_instrument_id() {
635 let market = HyperliquidMarket::Hip3 {
636 base: "BTC".to_string(),
637 quote: "USDE".to_string(),
638 dex: "hyna".to_string(),
639 dex_index: 4,
640 asset_index: 1,
641 instrument_meta: None,
642 };
643 assert_eq!(market.instrument_id().as_str(), "hyna:BTC-PERP");
644 assert_eq!(market.effective_asset_id(), 140001);
646 assert!(!market.is_spot());
647 assert!(!market.is_outcome());
648 assert!(market.uses_alternate_collateral());
649 assert_eq!(market.dex_name(), Some("hyna"));
650 }
651
652 #[test]
653 fn test_hip3_asset_id_calculation() {
654 let market = HyperliquidMarket::Hip3 {
656 base: "TEST".to_string(),
657 quote: "USDC".to_string(),
658 dex: "test".to_string(),
659 dex_index: 1,
660 asset_index: 0,
661 instrument_meta: None,
662 };
663 assert_eq!(market.effective_asset_id(), 110000);
664
665 let market2 = HyperliquidMarket::Hip3 {
667 base: "TEST".to_string(),
668 quote: "USDC".to_string(),
669 dex: "test".to_string(),
670 dex_index: 4,
671 asset_index: 1,
672 instrument_meta: None,
673 };
674 assert_eq!(market2.effective_asset_id(), 140001);
675 }
676
677 #[test]
678 fn test_market_enum_serde() {
679 let json = r#"{
680 "exchange": "hyperliquid",
681 "type": "perp",
682 "base": "BTC",
683 "quote": "USDC",
684 "index": 0
685 }"#;
686
687 let market: Market = serde_json::from_str(json).unwrap();
688 assert_eq!(market.instrument_id().as_str(), "BTC-PERP");
689 assert!(!market.is_spot());
690 assert!(!market.is_outcome());
691 }
692
693 #[test]
694 fn test_hip3_market_serde() {
695 let json = r#"{
696 "exchange": "hyperliquid",
697 "type": "hip3",
698 "base": "HFUN",
699 "quote": "USDC",
700 "dex": "hypurrfun",
701 "dex_index": 5,
702 "asset_index": 0
703 }"#;
704
705 let market: Market = serde_json::from_str(json).unwrap();
706 assert_eq!(market.instrument_id().as_str(), "hypurrfun:HFUN-PERP");
707
708 let hip3 = market.hip3_config().unwrap();
709 assert_eq!(hip3.dex_name, "hypurrfun");
710 assert_eq!(hip3.dex_index, 5);
711 }
712
713 #[test]
718 fn test_outcome_encoding() {
719 assert_eq!(HyperliquidMarket::outcome_encoding(516, 0), 5160);
721 assert_eq!(HyperliquidMarket::outcome_encoding(516, 1), 5161);
723 assert_eq!(HyperliquidMarket::outcome_encoding(9, 0), 90);
725 assert_eq!(HyperliquidMarket::outcome_encoding(10, 1), 101);
727 }
728
729 #[test]
730 fn test_outcome_market_instrument_id() {
731 let market = HyperliquidMarket::Outcome {
732 name: "BTC > 69070".to_string(),
733 outcome_id: 516,
734 side: 0,
735 instrument_meta: None,
736 };
737 assert_eq!(market.instrument_id().as_str(), "#5160-OUTCOME");
738 }
739
740 #[test]
741 fn test_outcome_market_effective_asset_id() {
742 let yes = HyperliquidMarket::Outcome {
744 name: "BTC > 69070".to_string(),
745 outcome_id: 516,
746 side: 0,
747 instrument_meta: None,
748 };
749 assert_eq!(yes.effective_asset_id(), 100_005_160);
750
751 let no = HyperliquidMarket::Outcome {
753 name: "BTC > 69070".to_string(),
754 outcome_id: 516,
755 side: 1,
756 instrument_meta: None,
757 };
758 assert_eq!(no.effective_asset_id(), 100_005_161);
759 }
760
761 #[test]
762 fn test_outcome_market_coin_name() {
763 let market = HyperliquidMarket::Outcome {
764 name: "BTC > 69070".to_string(),
765 outcome_id: 516,
766 side: 0,
767 instrument_meta: None,
768 };
769 assert_eq!(market.coin_name(), Some("#5160".to_string()));
770 }
771
772 #[test]
773 fn test_outcome_market_properties() {
774 let market = HyperliquidMarket::Outcome {
775 name: "HYPE > 200".to_string(),
776 outcome_id: 686,
777 side: 1,
778 instrument_meta: None,
779 };
780 assert!(market.is_outcome());
781 assert!(!market.is_spot());
782 assert_eq!(market.base(), "HYPE > 200");
783 assert_eq!(market.quote(), "USDH");
784 assert_eq!(market.spot_coin(), None);
785 assert_eq!(market.spot_market_index(), None);
786 assert_eq!(market.hip3_config(), None);
787 }
788
789 #[test]
790 fn test_outcome_market_serde() {
791 let json = r#"{
792 "exchange": "hyperliquid",
793 "type": "outcome",
794 "name": "BTC > 69070",
795 "outcome_id": 516,
796 "side": 0
797 }"#;
798
799 let market: Market = serde_json::from_str(json).unwrap();
800 assert_eq!(market.instrument_id().as_str(), "#5160-OUTCOME");
801 assert!(market.is_outcome());
802 assert!(!market.is_spot());
803 assert_eq!(market.instrument_kind(), InstrumentKind::Outcome);
804 }
805
806 #[test]
807 fn test_outcome_market_kind() {
808 let outcome = Market::Hyperliquid(HyperliquidMarket::Outcome {
809 name: "test".to_string(),
810 outcome_id: 10,
811 side: 0,
812 instrument_meta: None,
813 });
814 assert_eq!(outcome.instrument_kind(), InstrumentKind::Outcome);
815
816 let perp = Market::Hyperliquid(HyperliquidMarket::Perp {
817 base: "BTC".to_string(),
818 quote: "USDC".to_string(),
819 index: 0,
820 instrument_meta: None,
821 });
822 assert_eq!(perp.instrument_kind(), InstrumentKind::Perp);
823
824 let spot = Market::Hyperliquid(HyperliquidMarket::Spot {
825 base: "HYPE".to_string(),
826 quote: "USDC".to_string(),
827 index: 10107,
828 instrument_meta: None,
829 });
830 assert_eq!(spot.instrument_kind(), InstrumentKind::Spot);
831 }
832}