Skip to main content

bot_core/
market.rs

1//! Market types: unified market configuration enum.
2//!
3//! This module provides a type-safe `Market` enum that replaces scattered fields
4//! (instrument, market_index, is_spot, hip3) with a single source of truth.
5
6use rust_decimal::Decimal;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use crate::{
11    AssetId, Environment, ExchangeId, ExchangeInstance, InstrumentId, InstrumentKind, MarketIndex,
12};
13
14// =============================================================================
15// Top-Level Market Enum
16// =============================================================================
17
18/// Unified market configuration — replaces scattered config fields.
19///
20/// This is the canonical representation of "where to trade".
21/// All exchange/market-type-specific details are encapsulated here.
22#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
23#[serde(tag = "exchange")]
24pub enum Market {
25    /// Hyperliquid exchange markets
26    #[serde(rename = "hyperliquid")]
27    Hyperliquid(HyperliquidMarket),
28    // Future: Binance, Bybit, etc.
29    // #[serde(rename = "binance")]
30    // Binance(BinanceMarket),
31}
32
33impl Market {
34    /// Get the canonical instrument ID for engine use.
35    pub fn instrument_id(&self) -> InstrumentId {
36        match self {
37            Market::Hyperliquid(hl) => hl.instrument_id(),
38        }
39    }
40
41    /// Get the exchange-specific market index.
42    pub fn market_index(&self) -> MarketIndex {
43        match self {
44            Market::Hyperliquid(hl) => hl.market_index(),
45        }
46    }
47
48    /// Check if this is a spot market.
49    pub fn is_spot(&self) -> bool {
50        match self {
51            Market::Hyperliquid(hl) => hl.is_spot(),
52        }
53    }
54
55    /// Check if this market settles like spot with no margin/leverage.
56    pub fn is_spot_like(&self) -> bool {
57        match self {
58            Market::Hyperliquid(hl) => hl.is_spot_like(),
59        }
60    }
61
62    /// Check if this market uses margin/leverage.
63    pub fn uses_margin(&self) -> bool {
64        !self.is_spot_like()
65    }
66
67    /// Get the base asset (e.g., "BTC", "HYPE").
68    pub fn base(&self) -> &str {
69        match self {
70            Market::Hyperliquid(hl) => hl.base(),
71        }
72    }
73
74    /// Get the quote asset (e.g., "USDC", "USDE").
75    pub fn quote(&self) -> &str {
76        match self {
77            Market::Hyperliquid(hl) => hl.quote(),
78        }
79    }
80
81    /// Get the base asset ID.
82    pub fn base_asset(&self) -> AssetId {
83        AssetId::new(self.base())
84    }
85
86    /// Get the quote asset ID.
87    pub fn quote_asset(&self) -> AssetId {
88        AssetId::new(self.quote())
89    }
90
91    /// Get the instrument kind (Spot, Perp, or Outcome).
92    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    /// Check if this is a prediction market (outcome).
103    pub fn is_outcome(&self) -> bool {
104        match self {
105            Market::Hyperliquid(hl) => hl.is_outcome(),
106        }
107    }
108
109    /// Get outcome parameters (outcome_id, side, name) if this is an Outcome market.
110    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    /// Get the exchange instance for this market.
123    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    /// Get the effective asset ID for order placement (Hyperliquid-specific).
132    pub fn effective_asset_id(&self) -> u32 {
133        match self {
134            Market::Hyperliquid(hl) => hl.effective_asset_id(),
135        }
136    }
137
138    /// Get the spot coin name for alias resolution (e.g., "@107" -> "HYPE").
139    pub fn spot_coin(&self) -> Option<String> {
140        match self {
141            Market::Hyperliquid(hl) => hl.spot_coin(),
142        }
143    }
144
145    /// Get the spot market index for price lookups (e.g., 10107 for HYPE).
146    pub fn spot_market_index(&self) -> Option<u32> {
147        match self {
148            Market::Hyperliquid(hl) => hl.spot_market_index(),
149        }
150    }
151
152    /// Get HIP-3 configuration if this is a HIP-3 market.
153    pub fn hip3_config(&self) -> Option<Hip3MarketConfig> {
154        match self {
155            Market::Hyperliquid(hl) => hl.hip3_config(),
156        }
157    }
158
159    /// Get instrument metadata if configured.
160    pub fn instrument_meta(&self) -> Option<&InstrumentMetaConfig> {
161        match self {
162            Market::Hyperliquid(hl) => hl.instrument_meta(),
163        }
164    }
165
166    /// Optional exclusive price bounds for markets with bounded price domains.
167    pub fn price_bounds(&self) -> Option<(Decimal, Decimal)> {
168        match self {
169            Market::Hyperliquid(hl) => hl.price_bounds(),
170        }
171    }
172
173    /// Validate market-specific invariants.
174    pub fn validation_errors(&self) -> Vec<String> {
175        match self {
176            Market::Hyperliquid(hl) => hl.validation_errors(),
177        }
178    }
179}
180
181// =============================================================================
182// Hyperliquid Markets
183// =============================================================================
184
185/// Hyperliquid market types — Perp, Spot, HIP-3, or Outcome.
186#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
187#[serde(tag = "type")]
188pub enum HyperliquidMarket {
189    /// Standard perpetual contract on main Hyperliquid.
190    #[serde(rename = "perp")]
191    Perp {
192        /// Base asset (e.g., "BTC", "ETH")
193        base: String,
194        /// Quote asset (always "USDC" for main perps)
195        #[serde(default = "default_usdc")]
196        quote: String,
197        /// Asset index on Hyperliquid (e.g., 0 for BTC)
198        index: u32,
199        /// Instrument metadata (tick/lot sizes)
200        #[serde(default)]
201        instrument_meta: Option<InstrumentMetaConfig>,
202    },
203
204    /// Spot market on Hyperliquid.
205    #[serde(rename = "spot")]
206    Spot {
207        /// Base asset (e.g., "HYPE", "PURR")
208        base: String,
209        /// Quote asset (e.g., "USDC")
210        #[serde(default = "default_usdc")]
211        quote: String,
212        /// Spot market index (e.g., 10107 for HYPE/USDC)
213        index: u32,
214        /// Instrument metadata (tick/lot sizes)
215        #[serde(default)]
216        instrument_meta: Option<InstrumentMetaConfig>,
217    },
218
219    /// HIP-3 builder-deployed perpetual DEX.
220    #[serde(rename = "hip3")]
221    Hip3 {
222        /// Base asset (e.g., "BTC", "HFUN")
223        base: String,
224        /// Quote currency (e.g., "USDC", "USDE", "USDH")
225        quote: String,
226        /// DEX name (e.g., "hyna", "hypurrfun")
227        dex: String,
228        /// DEX index in perpDexs() array (starts at 1)
229        dex_index: u32,
230        /// Asset index within this DEX's meta.universe
231        asset_index: u32,
232        /// Instrument metadata (tick/lot sizes)
233        #[serde(default)]
234        instrument_meta: Option<InstrumentMetaConfig>,
235    },
236
237    /// Prediction market outcome.
238    ///
239    /// Outcomes trade like spot but use asset ID = 100_000_000 + encoding,
240    /// where encoding = 10 * outcome_id + side. Live outcome books quote in USDH.
241    #[serde(rename = "outcome")]
242    Outcome {
243        /// Human-readable name (e.g., "BTC > 69070")
244        name: String,
245        /// Outcome ID from outcomeMeta (e.g., 516)
246        outcome_id: u32,
247        /// Side: 0 = Yes, 1 = No
248        side: u8,
249        /// Instrument metadata (tick/lot sizes)
250        #[serde(default)]
251        instrument_meta: Option<InstrumentMetaConfig>,
252    },
253}
254
255fn default_usdc() -> String {
256    "USDC".to_string()
257}
258
259impl HyperliquidMarket {
260    /// Calculate the encoding for an outcome: `10 * outcome_id + side`.
261    pub fn outcome_encoding(outcome_id: u32, side: u8) -> u32 {
262        10 * outcome_id + side as u32
263    }
264
265    /// Get the canonical instrument ID.
266    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    /// Get the market index.
281    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                // HIP-3 effective market index for internal routing
291                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    /// Check if this is a spot market.
300    pub fn is_spot(&self) -> bool {
301        matches!(self, Self::Spot { .. })
302    }
303
304    /// Check if this market settles like spot with no margin/leverage.
305    pub fn is_spot_like(&self) -> bool {
306        matches!(self, Self::Spot { .. } | Self::Outcome { .. })
307    }
308
309    /// Check if this is a prediction market outcome.
310    pub fn is_outcome(&self) -> bool {
311        matches!(self, Self::Outcome { .. })
312    }
313
314    /// Get the base asset.
315    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    /// Get the quote asset.
325    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    /// Get the effective asset ID for order placement.
335    ///
336    /// - Perp: returns the index directly
337    /// - Spot: returns the index directly (e.g., 10107)
338    /// - Hip3: calculates 110000 + ((dex_index-1) * 10000) + asset_index
339    /// - Outcome: returns 100_000_000 + encoding
340    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    /// Get the coin name used in allMids/fills lookups.
356    ///
357    /// - Outcome: `#<encoding>` (e.g., `#5160`)
358    /// - Spot: base coin name (e.g., `HYPE`)
359    /// - Others: None (uses standard name)
360    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    /// Get the spot coin name for @tokenId alias resolution.
374    pub fn spot_coin(&self) -> Option<String> {
375        match self {
376            Self::Spot { base, .. } => Some(base.clone()),
377            _ => None,
378        }
379    }
380
381    /// Get the spot market index for price lookups.
382    pub fn spot_market_index(&self) -> Option<u32> {
383        match self {
384            Self::Spot { index, .. } => Some(*index),
385            _ => None,
386        }
387    }
388
389    /// Get HIP-3 configuration if applicable.
390    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    /// Check if this HIP-3 market uses non-USDC collateral.
409    pub fn uses_alternate_collateral(&self) -> bool {
410        match self {
411            Self::Hip3 { quote, .. } => quote.to_uppercase() != "USDC",
412            _ => false,
413        }
414    }
415
416    /// Get the DEX name for API calls (None for non-HIP3).
417    pub fn dex_name(&self) -> Option<&str> {
418        match self {
419            Self::Hip3 { dex, .. } => Some(dex.as_str()),
420            _ => None,
421        }
422    }
423
424    // Private helper for HIP-3 asset ID calculation
425    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    /// Get instrument metadata if configured.
430    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    /// Optional exclusive price bounds for markets with bounded price domains.
448    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    /// Validate Hyperliquid market-specific invariants.
456    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// =============================================================================
544// HIP-3 Config (for exchange client compatibility)
545// =============================================================================
546
547/// HIP-3 configuration extracted from Market enum.
548///
549/// This struct provides compatibility with existing `HyperliquidClient`
550/// which expects `hip3: Option<Hip3Config>`.
551#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
552pub struct Hip3MarketConfig {
553    /// DEX name used by Hyperliquid API calls.
554    pub dex_name: String,
555    /// One-based DEX index from Hyperliquid `perpDexs`.
556    pub dex_index: u32,
557    /// Collateral/quote currency used by the HIP-3 DEX.
558    pub quote_currency: String,
559    /// Asset index inside the DEX universe.
560    pub asset_index: u32,
561}
562
563impl Hip3MarketConfig {
564    /// Calculate the HIP-3 asset ID for order placement.
565    pub fn calculate_asset_id(&self) -> u32 {
566        110_000 + (self.dex_index.saturating_sub(1) * 10_000) + self.asset_index
567    }
568
569    /// Check if this DEX uses non-USDC collateral.
570    pub fn uses_alternate_collateral(&self) -> bool {
571        self.quote_currency.to_uppercase() != "USDC"
572    }
573}
574
575// =============================================================================
576// InstrumentMeta Builder
577// =============================================================================
578
579/// Configuration for instrument metadata (tick/lot sizes).
580#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
581pub struct InstrumentMetaConfig {
582    /// Tick size for price rounding
583    pub tick_size: Decimal,
584    /// Lot size for quantity rounding
585    pub lot_size: Decimal,
586    /// Minimum quantity
587    #[serde(default)]
588    pub min_qty: Option<Decimal>,
589    /// Minimum notional value
590    #[serde(default)]
591    pub min_notional: Option<Decimal>,
592}
593
594// =============================================================================
595// Tests
596// =============================================================================
597
598#[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        // HIP-3 asset ID: 110000 + (4-1)*10000 + 1 = 140001
645        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        // DEX index 1, asset index 0 → 110000
655        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        // DEX index 4, asset index 1 → 140001
666        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    // =========================================================================
714    // Outcome tests
715    // =========================================================================
716
717    #[test]
718    fn test_outcome_encoding() {
719        // outcome 516, side 0 (Yes) → encoding 5160
720        assert_eq!(HyperliquidMarket::outcome_encoding(516, 0), 5160);
721        // outcome 516, side 1 (No) → encoding 5161
722        assert_eq!(HyperliquidMarket::outcome_encoding(516, 1), 5161);
723        // outcome 9, side 0 → encoding 90
724        assert_eq!(HyperliquidMarket::outcome_encoding(9, 0), 90);
725        // outcome 10, side 1 → encoding 101
726        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        // outcome 516, side 0 → asset_id = 100_000_000 + 5160 = 100_005_160
743        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        // outcome 516, side 1 → asset_id = 100_000_000 + 5161 = 100_005_161
752        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}