1use anyhow::Context;
17use nautilus_core::{Params, UUID4, UnixNanos};
18use nautilus_model::{
19 data::TradeTick,
20 enums::{
21 AggressorSide, AssetClass, CurrencyType, LiquiditySide, OrderSide, OrderStatus, OrderType,
22 PositionSideSpecified, TimeInForce, TriggerType,
23 },
24 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
25 instruments::{BinaryOption, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
26 reports::{FillReport, OrderStatusReport, PositionStatusReport},
27 types::{Currency, Money, Price, Quantity},
28};
29use rust_decimal::Decimal;
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32use ustr::Ustr;
33
34use super::models::{
35 AssetPosition, HyperliquidFill, HyperliquidRecentTrade, OutcomeMarket, OutcomeMeta,
36 OutcomeQuestion, PerpMeta, SpotBalance, SpotMeta,
37};
38use crate::{
39 common::{
40 consts::HYPERLIQUID_VENUE,
41 enums::{
42 HyperliquidFillDirection, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
43 HyperliquidSide, HyperliquidTimeInForce,
44 },
45 parse::{
46 format_outcome_nautilus_symbol, is_conditional_order_data, make_fill_trade_id,
47 millis_to_nanos, parse_trigger_order_type,
48 },
49 types::HyperliquidAssetId,
50 },
51 websocket::messages::{WsBasicOrderData, WsOrderData},
52};
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56pub enum HyperliquidMarketType {
57 Perp,
59 Spot,
61 Outcome,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct HyperliquidOutcomeMetadata {
73 pub outcome_index: u32,
75 pub outcome_side: u8,
77 pub market_name: Ustr,
79 pub side_name: Option<Ustr>,
83 pub description: Option<Ustr>,
85 pub activation_ns: UnixNanos,
87 pub expiration_ns: UnixNanos,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub info: Option<Params>,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct HyperliquidInstrumentDef {
101 pub symbol: Ustr,
103 pub raw_symbol: Ustr,
108 pub base: Ustr,
110 pub quote: Ustr,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub settlement: Option<Ustr>,
115 pub market_type: HyperliquidMarketType,
117 pub asset_index: u32,
122 pub price_decimals: u32,
124 pub size_decimals: u32,
126 pub tick_size: Decimal,
128 pub lot_size: Decimal,
130 pub max_leverage: Option<u32>,
132 pub only_isolated: bool,
134 pub is_hip3: bool,
136 pub active: bool,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub outcome: Option<HyperliquidOutcomeMetadata>,
142 pub raw_data: String,
144}
145
146#[must_use]
153fn sanitize_symbol(value: &str) -> std::borrow::Cow<'_, str> {
154 if value.bytes().any(|b| b == b'*' || b == b'?') {
155 let mut out = String::with_capacity(value.len());
156 for ch in value.chars() {
157 out.push(if ch == '*' || ch == '?' { 'x' } else { ch });
158 }
159 std::borrow::Cow::Owned(out)
160 } else {
161 std::borrow::Cow::Borrowed(value)
162 }
163}
164
165pub fn parse_perp_instruments(
179 meta: &PerpMeta,
180 asset_index_base: u32,
181) -> Result<Vec<HyperliquidInstrumentDef>, String> {
182 Ok(parse_perp_instruments_with_settlement(
183 meta,
184 asset_index_base,
185 DEFAULT_PERP_SETTLEMENT_CURRENCY,
186 ))
187}
188
189pub(crate) fn parse_perp_instruments_with_settlement(
190 meta: &PerpMeta,
191 asset_index_base: u32,
192 settlement_currency: &str,
193) -> Vec<HyperliquidInstrumentDef> {
194 const PERP_MAX_DECIMALS: i32 = 6;
195
196 let mut defs = Vec::new();
197
198 for (index, asset) in meta.universe.iter().enumerate() {
199 let is_delisted = asset.is_delisted.unwrap_or(false);
200
201 let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
202 let tick_size = pow10_neg(price_decimals);
203 let lot_size = pow10_neg(asset.sz_decimals);
204
205 let symbol = format!("{}-USD-PERP", sanitize_symbol(&asset.name));
206
207 let raw_symbol: Ustr = asset.name.as_str().into();
208
209 let def = HyperliquidInstrumentDef {
210 symbol: symbol.into(),
211 raw_symbol,
212 base: asset.name.clone().into(),
213 quote: "USD".into(),
214 settlement: Some(settlement_currency.into()),
215 market_type: HyperliquidMarketType::Perp,
216 asset_index: asset_index_base + index as u32,
217 price_decimals,
218 size_decimals: asset.sz_decimals,
219 tick_size,
220 lot_size,
221 max_leverage: asset.max_leverage,
222 only_isolated: asset.only_isolated.unwrap_or(false),
223 is_hip3: asset_index_base > 0,
224 active: !is_delisted,
225 outcome: None,
226 raw_data: serde_json::to_string(asset).unwrap_or_default(),
227 };
228
229 defs.push(def);
230 }
231
232 defs
233}
234
235const DEFAULT_PERP_COLLATERAL_TOKEN: u32 = 0;
236const DEFAULT_PERP_SETTLEMENT_CURRENCY: &str = "USDC";
237
238pub(crate) fn resolve_perp_settlement_currency(
239 meta: &PerpMeta,
240 spot_meta: Option<&SpotMeta>,
241) -> Result<Ustr, String> {
242 let Some(collateral_token) = meta.collateral_token else {
243 return Ok(DEFAULT_PERP_SETTLEMENT_CURRENCY.into());
244 };
245
246 if collateral_token == DEFAULT_PERP_COLLATERAL_TOKEN {
247 return Ok(DEFAULT_PERP_SETTLEMENT_CURRENCY.into());
248 }
249
250 let spot_meta = spot_meta.ok_or_else(|| {
251 format!("Spot metadata required to resolve perp collateral token {collateral_token}")
252 })?;
253 let token = spot_meta
254 .tokens
255 .iter()
256 .find(|token| token.index == collateral_token)
257 .ok_or_else(|| {
258 format!("Perp collateral token index {collateral_token} not found in spot metadata")
259 })?;
260
261 Ok(token.name.as_str().into())
262}
263
264pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
272 const SPOT_MAX_DECIMALS: i32 = 8; const SPOT_INDEX_OFFSET: u32 = 10000; let mut defs = Vec::new();
276
277 let mut tokens_by_index = ahash::AHashMap::new();
279 for token in &meta.tokens {
280 tokens_by_index.insert(token.index, token);
281 }
282
283 for pair in &meta.universe {
284 let base_token = tokens_by_index
288 .get(&pair.tokens[0])
289 .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
290 let quote_token = tokens_by_index
291 .get(&pair.tokens[1])
292 .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
293
294 let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
295 let tick_size = pow10_neg(price_decimals);
296 let lot_size = pow10_neg(base_token.sz_decimals);
297
298 let symbol = format!(
299 "{}-{}-SPOT",
300 sanitize_symbol(&base_token.name),
301 sanitize_symbol("e_token.name),
302 );
303
304 let raw_symbol: Ustr = if base_token.name == "PURR" {
308 pair.name.as_str().into()
309 } else {
310 format!("@{}", pair.index).into()
311 };
312
313 let def = HyperliquidInstrumentDef {
314 symbol: symbol.into(),
315 raw_symbol,
316 base: base_token.name.clone().into(),
317 quote: quote_token.name.clone().into(),
318 settlement: None,
319 market_type: HyperliquidMarketType::Spot,
320 asset_index: SPOT_INDEX_OFFSET + pair.index,
321 price_decimals,
322 size_decimals: base_token.sz_decimals,
323 tick_size,
324 lot_size,
325 max_leverage: None,
326 only_isolated: false,
327 is_hip3: false,
328 active: pair.is_canonical, outcome: None,
330 raw_data: serde_json::to_string(pair).unwrap_or_default(),
331 };
332
333 defs.push(def);
334 }
335
336 defs.sort_by(|a, b| {
341 b.active
342 .cmp(&a.active)
343 .then(a.asset_index.cmp(&b.asset_index))
344 });
345
346 Ok(defs)
347}
348
349pub const OUTCOME_PRICE_DECIMALS: u32 = 4;
355pub const OUTCOME_SIZE_DECIMALS: u32 = 2;
356
357pub fn parse_outcome_instruments(
373 meta: &OutcomeMeta,
374) -> Result<Vec<HyperliquidInstrumentDef>, String> {
375 let mut defs = Vec::with_capacity(meta.outcomes.len() * 2);
376
377 for market in &meta.outcomes {
378 for side in 0u8..=1u8 {
379 defs.push(build_outcome_def(market, side, meta)?);
380 }
381 }
382
383 Ok(defs)
384}
385
386fn build_outcome_def(
387 market: &OutcomeMarket,
388 side: u8,
389 meta: &OutcomeMeta,
390) -> Result<HyperliquidInstrumentDef, String> {
391 let outcome_index = market.outcome;
392 let asset_id = HyperliquidAssetId::outcome(outcome_index, side);
393 let encoding = asset_id.outcome_encoding().ok_or_else(|| {
394 format!("Invalid outcome encoding for outcome={outcome_index} side={side}")
395 })?;
396
397 let token = format!("+{encoding}");
398 let coin = format!("#{encoding}");
399 let symbol = format_outcome_nautilus_symbol(outcome_index, side);
400
401 let side_name = market
402 .side_specs
403 .get(usize::from(side))
404 .map(|spec| Ustr::from(spec.name.as_str()))
405 .or_else(|| Some(Ustr::from(default_side_label(side))));
406
407 let description = if market.description.is_empty() {
408 None
409 } else {
410 Some(Ustr::from(market.description.as_str()))
411 };
412
413 let parent_question = meta.parent_question(outcome_index);
414 let expiration_ns = resolve_outcome_expiration_ns(market, meta);
415
416 let info = build_outcome_info(
417 market,
418 side,
419 encoding,
420 asset_id.to_raw(),
421 side_name.as_ref().map(Ustr::as_str),
422 parent_question,
423 );
424
425 let outcome_metadata = HyperliquidOutcomeMetadata {
426 outcome_index,
427 outcome_side: side,
428 market_name: Ustr::from(market.name.as_str()),
429 side_name,
430 description,
431 activation_ns: UnixNanos::default(),
432 expiration_ns,
433 info: Some(info),
434 };
435
436 Ok(HyperliquidInstrumentDef {
437 symbol: Ustr::from(symbol.as_str()),
438 raw_symbol: Ustr::from(coin.as_str()),
439 base: Ustr::from(token.as_str()),
440 quote: "USDH".into(),
441 settlement: None,
442 market_type: HyperliquidMarketType::Outcome,
443 asset_index: asset_id.to_raw(),
444 price_decimals: OUTCOME_PRICE_DECIMALS,
445 size_decimals: OUTCOME_SIZE_DECIMALS,
446 tick_size: pow10_neg(OUTCOME_PRICE_DECIMALS),
447 lot_size: pow10_neg(OUTCOME_SIZE_DECIMALS),
448 max_leverage: None,
449 only_isolated: false,
450 is_hip3: false,
451 active: true,
452 outcome: Some(outcome_metadata),
453 raw_data: serde_json::to_string(market).unwrap_or_default(),
454 })
455}
456
457fn default_side_label(side: u8) -> &'static str {
459 if side == 0 { "Yes" } else { "No" }
460}
461
462fn parse_description_fields(description: &str) -> impl Iterator<Item = (String, String)> + '_ {
466 description
467 .split('|')
468 .filter_map(|piece| piece.split_once(':'))
469 .map(|(key, value)| (camel_to_snake(key.trim()), value.trim().to_string()))
470}
471
472fn camel_to_snake(s: &str) -> String {
473 let mut out = String::with_capacity(s.len() + 4);
474 for (i, ch) in s.char_indices() {
475 if ch.is_ascii_uppercase() {
476 if i > 0 {
477 out.push('_');
478 }
479 out.push(ch.to_ascii_lowercase());
480 } else {
481 out.push(ch);
482 }
483 }
484 out
485}
486
487fn build_outcome_info(
488 market: &OutcomeMarket,
489 side: u8,
490 encoding: u32,
491 asset_id_raw: u32,
492 side_name: Option<&str>,
493 parent_question: Option<&OutcomeQuestion>,
494) -> Params {
495 let mut info = Params::new();
496
497 info.insert("outcome_index".into(), json!(market.outcome));
498 info.insert("outcome_side".into(), json!(side));
499 if let Some(name) = side_name {
500 info.insert("side_name".into(), Value::String(name.to_string()));
501 }
502 info.insert("encoding".into(), json!(encoding));
503 info.insert("asset_id".into(), json!(asset_id_raw));
504 info.insert("market_name".into(), Value::String(market.name.clone()));
505
506 for (key, value) in parse_description_fields(&market.description) {
510 match key.as_str() {
511 "index" => {
512 if let Ok(named) = value.parse::<u32>() {
513 info.insert("named_index".into(), json!(named));
514 }
515 }
516 "other" => {
517 info.insert("is_fallback".into(), json!(true));
518 }
519 _ => {
520 info.insert(key, Value::String(value));
521 }
522 }
523 }
524
525 if market.description.trim() == "other" {
529 info.insert("is_fallback".into(), json!(true));
530 }
531
532 if let Some(question) = parent_question {
533 info.insert("question".into(), json!(question.question));
534 info.insert("question_name".into(), Value::String(question.name.clone()));
535 for (key, value) in parse_description_fields(&question.description) {
536 let prefixed = format!("question_{key}");
537 info.insert(prefixed, Value::String(value));
538 }
539 }
540
541 info
542}
543
544fn pow10_neg(decimals: u32) -> Decimal {
545 if decimals == 0 {
546 return Decimal::ONE;
547 }
548
549 Decimal::from_i128_with_scale(1, decimals)
551}
552
553fn resolve_outcome_expiration_ns(market: &OutcomeMarket, meta: &OutcomeMeta) -> UnixNanos {
557 if let Some(ns) = parse_expiry_from_description(&market.description) {
558 return ns;
559 }
560
561 meta.parent_question(market.outcome)
562 .and_then(|q| parse_expiry_from_description(&q.description))
563 .unwrap_or_default()
564}
565
566fn parse_expiry_from_description(description: &str) -> Option<UnixNanos> {
567 description
568 .split('|')
569 .filter_map(|piece| piece.split_once(':'))
570 .find_map(|(key, value)| (key == "expiry").then_some(value))
571 .and_then(parse_outcome_expiry_ns)
572}
573
574fn parse_outcome_expiry_ns(s: &str) -> Option<UnixNanos> {
576 let (date_part, time_part) = s.split_once('-')?;
577 if date_part.len() != 8 || time_part.len() != 4 {
578 return None;
579 }
580
581 let year: i32 = date_part[0..4].parse().ok()?;
582 let month: u32 = date_part[4..6].parse().ok()?;
583 let day: u32 = date_part[6..8].parse().ok()?;
584 let hour: u32 = time_part[0..2].parse().ok()?;
585 let minute: u32 = time_part[2..4].parse().ok()?;
586
587 let datetime = chrono::NaiveDate::from_ymd_opt(year, month, day)?
588 .and_hms_opt(hour, minute, 0)?
589 .and_utc();
590 let nanos = datetime.timestamp_nanos_opt()?;
591 u64::try_from(nanos).ok().map(UnixNanos::from)
592}
593
594#[derive(Debug, Clone, Copy, PartialEq, Eq)]
596pub struct OutcomeSettlement {
597 pub outcome_index: u32,
599 pub outcome_side: u8,
601 pub final_value: u8,
603}
604
605#[must_use]
622pub fn derive_outcome_settlements(meta: &OutcomeMeta) -> Vec<OutcomeSettlement> {
623 let mut settlements = Vec::new();
624
625 for question in &meta.questions {
626 if question.settled_named_outcomes.is_empty() {
627 continue;
628 }
629
630 let losing_sides_won = |outcome_index: u32| -> [OutcomeSettlement; 2] {
631 [
633 OutcomeSettlement {
634 outcome_index,
635 outcome_side: 0,
636 final_value: 0,
637 },
638 OutcomeSettlement {
639 outcome_index,
640 outcome_side: 1,
641 final_value: 1,
642 },
643 ]
644 };
645
646 let winning_sides = |outcome_index: u32| -> [OutcomeSettlement; 2] {
647 [
649 OutcomeSettlement {
650 outcome_index,
651 outcome_side: 0,
652 final_value: 1,
653 },
654 OutcomeSettlement {
655 outcome_index,
656 outcome_side: 1,
657 final_value: 0,
658 },
659 ]
660 };
661
662 for outcome_index in &question.named_outcomes {
663 if question.settled_named_outcomes.contains(outcome_index) {
664 settlements.extend(winning_sides(*outcome_index));
665 } else {
666 settlements.extend(losing_sides_won(*outcome_index));
667 }
668 }
669
670 if let Some(fallback) = question.fallback_outcome {
673 settlements.extend(losing_sides_won(fallback));
674 }
675 }
676
677 settlements
678}
679
680pub fn get_currency(code: &str) -> Currency {
681 Currency::try_from_str(code).unwrap_or_else(|| {
682 let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
683 if let Err(e) = Currency::register(currency, false) {
684 log::error!("Failed to register currency '{code}': {e}");
685 }
686 currency
687 })
688}
689
690pub fn get_usdh_currency() -> Currency {
697 Currency::try_from_str("USDH").unwrap_or_else(|| {
698 let currency = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
699 if let Err(e) = Currency::register(currency, false) {
700 log::error!("Failed to register USDH currency: {e}");
701 }
702 currency
703 })
704}
705
706pub fn resolve_fee_currency(
722 fee_token: &str,
723 fee_amount: Decimal,
724 instrument: &dyn Instrument,
725) -> anyhow::Result<Currency> {
726 if is_outcome_side_token(fee_token) {
727 if !fee_amount.is_zero() {
728 anyhow::bail!(
729 "Outcome side token '{fee_token}' carried a non-zero fee {fee_amount}; \
730 venue does not denominate fees in side tokens",
731 );
732 }
733 return Ok(instrument.quote_currency());
734 }
735
736 if let Some(currency) = Currency::try_from_str(fee_token) {
737 return Ok(currency);
738 }
739
740 if fee_amount.is_zero() {
741 let fallback = instrument.quote_currency();
742 log::debug!(
743 "Unregistered fee token '{fee_token}' on zero-fee fill for {}; using {fallback} as fallback",
744 instrument.id(),
745 );
746 return Ok(fallback);
747 }
748
749 anyhow::bail!("Unknown fee token '{fee_token}' with non-zero fee {fee_amount}")
750}
751
752fn is_outcome_side_token(symbol: &str) -> bool {
753 let Some(rest) = symbol.strip_prefix('+') else {
754 return false;
755 };
756 !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
757}
758
759const HYPERLIQUID_MIN_ORDER_NOTIONAL: Decimal = Decimal::TEN;
763
764#[must_use]
768pub fn create_instrument_from_def(
769 def: &HyperliquidInstrumentDef,
770 ts_init: UnixNanos,
771) -> Option<InstrumentAny> {
772 let symbol = Symbol::new(def.symbol);
773 let venue = *HYPERLIQUID_VENUE;
774 let instrument_id = InstrumentId::new(symbol, venue);
775
776 let raw_symbol = Symbol::new(def.raw_symbol);
781 let price_increment = Price::from(def.tick_size.to_string());
782 let size_increment = Quantity::from(def.lot_size.to_string());
783
784 match def.market_type {
785 HyperliquidMarketType::Spot => {
786 let base_currency = get_currency(&def.base);
787 let quote_currency = get_currency(&def.quote);
788 let min_notional = Some(min_order_notional(quote_currency)?);
789
790 Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
791 instrument_id,
792 raw_symbol,
793 base_currency,
794 quote_currency,
795 def.price_decimals as u8,
796 def.size_decimals as u8,
797 price_increment,
798 size_increment,
799 None,
800 None,
801 None,
802 None,
803 None,
804 min_notional,
805 None,
806 None,
807 None,
808 None,
809 None,
810 None,
811 None,
812 None,
813 ts_init, ts_init,
815 )))
816 }
817 HyperliquidMarketType::Perp => {
818 let base_currency = get_currency(&def.base);
819 let quote_currency = get_currency(&def.quote);
820 let settlement_code = def
821 .settlement
822 .as_ref()
823 .map_or(DEFAULT_PERP_SETTLEMENT_CURRENCY, Ustr::as_str);
824 let settlement_currency = if settlement_code == "USDH" {
825 get_usdh_currency()
826 } else {
827 get_currency(settlement_code)
828 };
829 let min_notional = Some(min_order_notional(quote_currency)?);
830
831 Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
832 instrument_id,
833 raw_symbol,
834 base_currency,
835 quote_currency,
836 settlement_currency,
837 false,
838 def.price_decimals as u8,
839 def.size_decimals as u8,
840 price_increment,
841 size_increment,
842 None, None,
844 None,
845 None,
846 None,
847 min_notional,
848 None,
849 None,
850 None,
851 None,
852 None,
853 None,
854 None,
855 None,
856 ts_init, ts_init,
858 )))
859 }
860 HyperliquidMarketType::Outcome => {
861 let outcome = def.outcome.as_ref()?;
862 let currency = get_usdh_currency();
863
864 Some(InstrumentAny::BinaryOption(BinaryOption::new(
865 instrument_id,
866 raw_symbol,
867 AssetClass::Alternative,
868 currency,
869 outcome.activation_ns,
870 outcome.expiration_ns,
871 def.price_decimals as u8,
872 def.size_decimals as u8,
873 price_increment,
874 size_increment,
875 outcome.side_name,
876 outcome.description,
877 None, None, None, None, None, None, None, None, None, None, None, outcome.info.clone(),
889 ts_init,
890 ts_init,
891 )))
892 }
893 }
894}
895
896fn min_order_notional(currency: Currency) -> Option<Money> {
897 Money::from_decimal(HYPERLIQUID_MIN_ORDER_NOTIONAL, currency).ok()
898}
899
900#[must_use]
903pub fn instruments_from_defs(
904 defs: &[HyperliquidInstrumentDef],
905 ts_init: UnixNanos,
906) -> Vec<InstrumentAny> {
907 defs.iter()
908 .filter_map(|def| create_instrument_from_def(def, ts_init))
909 .collect()
910}
911
912#[must_use]
914pub fn instruments_from_defs_owned(
915 defs: Vec<HyperliquidInstrumentDef>,
916 ts_init: UnixNanos,
917) -> Vec<InstrumentAny> {
918 defs.into_iter()
919 .filter_map(|def| create_instrument_from_def(&def, ts_init))
920 .collect()
921}
922
923fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
924 match side {
925 HyperliquidSide::Buy => OrderSide::Buy,
926 HyperliquidSide::Sell => OrderSide::Sell,
927 }
928}
929
930pub fn parse_order_status_report_from_ws(
936 order_data: &WsOrderData,
937 instrument: &dyn Instrument,
938 account_id: AccountId,
939 ts_init: UnixNanos,
940) -> anyhow::Result<OrderStatusReport> {
941 parse_order_status_report_from_basic(
942 &order_data.order,
943 &order_data.status,
944 instrument,
945 account_id,
946 ts_init,
947 )
948}
949
950pub fn parse_order_status_report_from_basic(
956 order: &WsBasicOrderData,
957 status: &HyperliquidOrderStatusEnum,
958 instrument: &dyn Instrument,
959 account_id: AccountId,
960 ts_init: UnixNanos,
961) -> anyhow::Result<OrderStatusReport> {
962 let instrument_id = instrument.id();
963 let venue_order_id = VenueOrderId::new(order.oid.to_string());
964 let order_side = OrderSide::from(order.side);
965
966 let is_conditional = is_conditional_order_data(order.trigger_px, order.tpsl.as_ref());
967 let order_type = if is_conditional {
968 match (order.is_market, order.tpsl.as_ref()) {
969 (Some(is_market), Some(tpsl)) => parse_trigger_order_type(is_market, tpsl),
970 (None, Some(tpsl)) => parse_trigger_order_type(false, tpsl),
971 _ => OrderType::Limit,
972 }
973 } else {
974 OrderType::Limit
975 };
976
977 let time_in_force = match order.tif {
978 Some(HyperliquidTimeInForce::Ioc) => TimeInForce::Ioc,
979 _ => TimeInForce::Gtc,
980 };
981 let order_status = OrderStatus::from(*status);
982
983 let price_precision = instrument.price_precision();
984 let size_precision = instrument.size_precision();
985
986 let orig_sz = order.orig_sz;
987 let current_sz = order.sz;
988
989 let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
990 .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
991 let filled_sz = orig_sz.abs() - current_sz.abs();
992 let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
993 .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
994
995 let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
996 let ts_last = ts_accepted;
997 let report_id = UUID4::new();
998
999 let mut report = OrderStatusReport::new(
1000 account_id,
1001 instrument_id,
1002 None, venue_order_id,
1004 order_side,
1005 order_type,
1006 time_in_force,
1007 order_status,
1008 quantity,
1009 filled_qty,
1010 ts_accepted,
1011 ts_last,
1012 ts_init,
1013 Some(report_id),
1014 );
1015
1016 if let Some(cloid) = &order.cloid {
1018 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
1019 }
1020
1021 if matches!(order.tif, Some(HyperliquidTimeInForce::Alo)) {
1022 report = report.with_post_only(true);
1023 }
1024
1025 if let Some(reduce_only) = order.reduce_only {
1026 report = report.with_reduce_only(reduce_only);
1027 }
1028
1029 if !matches!(
1033 order_status,
1034 OrderStatus::Filled | OrderStatus::PartiallyFilled
1035 ) {
1036 let price = Price::from_decimal_dp(order.limit_px, price_precision)
1037 .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
1038 report = report.with_price(price);
1039 }
1040
1041 if is_conditional && let Some(trigger_px) = order.trigger_px {
1042 let trigger_price = Price::from_decimal_dp(trigger_px, price_precision)
1043 .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
1044 report = report
1045 .with_trigger_price(trigger_price)
1046 .with_trigger_type(TriggerType::Default);
1047 }
1048
1049 Ok(report)
1050}
1051
1052pub fn parse_recent_trade(
1065 trade: &HyperliquidRecentTrade,
1066 instrument: &InstrumentAny,
1067) -> anyhow::Result<TradeTick> {
1068 let price = Price::from_decimal_dp(trade.px, instrument.price_precision())
1069 .with_context(|| format!("Failed to create price from '{}'", trade.px))?;
1070
1071 let size = Quantity::from_decimal_dp(trade.sz.abs(), instrument.size_precision())
1072 .with_context(|| format!("Failed to create size from '{}'", trade.sz))?;
1073
1074 let aggressor = AggressorSide::from(trade.side);
1075 let trade_id = TradeId::new_checked(trade.tid.to_string())
1076 .context("invalid trade identifier in Hyperliquid recent trade")?;
1077 let ts_event = millis_to_nanos(trade.time)?;
1078
1079 TradeTick::new_checked(
1080 instrument.id(),
1081 price,
1082 size,
1083 aggressor,
1084 trade_id,
1085 ts_event,
1086 ts_event,
1087 )
1088 .context("failed to construct TradeTick from Hyperliquid recent trade")
1089}
1090
1091pub fn parse_fill_report(
1097 fill: &HyperliquidFill,
1098 instrument: &dyn Instrument,
1099 account_id: AccountId,
1100 ts_init: UnixNanos,
1101) -> anyhow::Result<FillReport> {
1102 let instrument_id = instrument.id();
1103 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
1104
1105 if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
1106 log::warn!(
1107 "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
1108 fill.oid,
1109 fill.px,
1110 fill.sz,
1111 );
1112 }
1113
1114 let trade_id = make_fill_trade_id(
1115 &fill.hash,
1116 fill.oid,
1117 fill.px,
1118 fill.sz,
1119 fill.time,
1120 fill.start_position,
1121 );
1122 let order_side = parse_fill_side(&fill.side);
1123
1124 let price_precision = instrument.price_precision();
1125 let size_precision = instrument.size_precision();
1126
1127 let last_px = Price::from_decimal_dp(fill.px, price_precision)
1128 .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
1129 let last_qty = Quantity::from_decimal_dp(fill.sz.abs(), size_precision)
1130 .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
1131
1132 let fee_amount = fill.fee;
1133
1134 let fee_currency = resolve_fee_currency(fill.fee_token.as_str(), fee_amount, instrument)?;
1135 let commission = Money::from_decimal(fee_amount, fee_currency)
1136 .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
1137
1138 let liquidity_side = if fill.crossed {
1140 LiquiditySide::Taker
1141 } else {
1142 LiquiditySide::Maker
1143 };
1144
1145 let ts_event = UnixNanos::from(fill.time * 1_000_000);
1146 let report_id = UUID4::new();
1147
1148 let report = FillReport::new(
1149 account_id,
1150 instrument_id,
1151 venue_order_id,
1152 trade_id,
1153 order_side,
1154 last_qty,
1155 last_px,
1156 commission,
1157 liquidity_side,
1158 None, None, ts_event,
1161 ts_init,
1162 Some(report_id),
1163 );
1164
1165 Ok(report)
1166}
1167
1168pub fn parse_position_status_report(
1174 position_data: &serde_json::Value,
1175 instrument: &dyn Instrument,
1176 account_id: AccountId,
1177 ts_init: UnixNanos,
1178) -> anyhow::Result<PositionStatusReport> {
1179 let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
1181 .context("failed to deserialize AssetPosition")?;
1182
1183 let position = &asset_position.position;
1184 let instrument_id = instrument.id();
1185
1186 let (position_side, quantity_value) = if position.szi.is_zero() {
1188 (PositionSideSpecified::Flat, Decimal::ZERO)
1189 } else if position.szi.is_sign_positive() {
1190 (PositionSideSpecified::Long, position.szi)
1191 } else {
1192 (PositionSideSpecified::Short, position.szi.abs())
1193 };
1194
1195 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1196 .context("failed to create quantity from decimal")?;
1197 let report_id = UUID4::new();
1198 let ts_last = ts_init;
1199 let avg_px_open = position.entry_px;
1200
1201 Ok(PositionStatusReport::new(
1203 account_id,
1204 instrument_id,
1205 position_side,
1206 quantity,
1207 ts_last,
1208 ts_init,
1209 Some(report_id),
1210 None, avg_px_open,
1212 ))
1213}
1214
1215pub fn parse_spot_position_status_report(
1225 balance: &SpotBalance,
1226 instrument: &dyn Instrument,
1227 account_id: AccountId,
1228 ts_init: UnixNanos,
1229) -> anyhow::Result<PositionStatusReport> {
1230 let (position_side, quantity_value) = if balance.total.is_zero() {
1231 (PositionSideSpecified::Flat, Decimal::ZERO)
1232 } else {
1233 (PositionSideSpecified::Long, balance.total)
1234 };
1235
1236 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1237 .context("failed to create spot quantity from decimal")?;
1238
1239 Ok(PositionStatusReport::new(
1240 account_id,
1241 instrument.id(),
1242 position_side,
1243 quantity,
1244 ts_init,
1245 ts_init,
1246 Some(UUID4::new()),
1247 None,
1248 balance.avg_entry_px(),
1249 ))
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254 use rstest::rstest;
1255 use rust_decimal_macros::dec;
1256
1257 use super::{
1258 super::models::{
1259 HyperliquidL2Book, OutcomeMarket, OutcomeMeta, OutcomeQuestion, OutcomeSideSpec,
1260 PerpAsset, SpotPair, SpotToken,
1261 },
1262 *,
1263 };
1264
1265 #[rstest]
1266 fn test_parse_fill_side() {
1267 assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
1268 assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
1269 }
1270
1271 #[rstest]
1272 fn test_pow10_neg() {
1273 assert_eq!(pow10_neg(0), dec!(1));
1274 assert_eq!(pow10_neg(1), dec!(0.1));
1275 assert_eq!(pow10_neg(5), dec!(0.00001));
1276 }
1277
1278 #[rstest]
1279 fn test_parse_perp_instruments() {
1280 let meta = PerpMeta {
1281 universe: vec![
1282 PerpAsset {
1283 name: "BTC".to_string(),
1284 sz_decimals: 5,
1285 max_leverage: Some(50),
1286 ..Default::default()
1287 },
1288 PerpAsset {
1289 name: "DELIST".to_string(),
1290 sz_decimals: 3,
1291 max_leverage: Some(10),
1292 only_isolated: Some(true),
1293 is_delisted: Some(true),
1294 ..Default::default()
1295 },
1296 ],
1297 margin_tables: vec![],
1298 collateral_token: None,
1299 };
1300
1301 let defs = parse_perp_instruments(&meta, 0).unwrap();
1302
1303 assert_eq!(defs.len(), 2);
1305
1306 let btc = &defs[0];
1307 assert_eq!(btc.symbol, "BTC-USD-PERP");
1308 assert_eq!(btc.base, "BTC");
1309 assert_eq!(btc.quote, "USD");
1310 assert_eq!(btc.settlement.as_ref().unwrap().as_str(), "USDC");
1311 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1312 assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
1314 assert_eq!(btc.tick_size, dec!(0.1));
1315 assert_eq!(btc.lot_size, dec!(0.00001));
1316 assert_eq!(btc.max_leverage, Some(50));
1317 assert!(!btc.only_isolated);
1318 assert!(btc.active);
1319
1320 let delist = &defs[1];
1321 assert_eq!(delist.symbol, "DELIST-USD-PERP");
1322 assert_eq!(delist.base, "DELIST");
1323 assert!(!delist.active); }
1325
1326 use crate::common::testing::load_test_data;
1327
1328 #[rstest]
1329 fn test_parse_perp_instruments_from_real_data() {
1330 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1331
1332 let defs = parse_perp_instruments(&meta, 0).unwrap();
1333
1334 assert_eq!(defs.len(), 3);
1336
1337 let btc = &defs[0];
1339 assert_eq!(btc.symbol, "BTC-USD-PERP");
1340 assert_eq!(btc.base, "BTC");
1341 assert_eq!(btc.quote, "USD");
1342 assert_eq!(btc.settlement.as_ref().unwrap().as_str(), "USDC");
1343 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1344 assert_eq!(btc.size_decimals, 5);
1345 assert_eq!(btc.max_leverage, Some(40));
1346 assert!(btc.active);
1347
1348 let eth = &defs[1];
1350 assert_eq!(eth.symbol, "ETH-USD-PERP");
1351 assert_eq!(eth.base, "ETH");
1352 assert_eq!(eth.size_decimals, 4);
1353 assert_eq!(eth.max_leverage, Some(25));
1354
1355 let atom = &defs[2];
1357 assert_eq!(atom.symbol, "ATOM-USD-PERP");
1358 assert_eq!(atom.base, "ATOM");
1359 assert_eq!(atom.size_decimals, 2);
1360 assert_eq!(atom.max_leverage, Some(5));
1361 }
1362
1363 #[rstest]
1364 fn test_parse_recent_trade() {
1365 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1366 let defs = parse_perp_instruments(&meta, 0).unwrap();
1367 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1368
1369 let trade = HyperliquidRecentTrade {
1370 coin: Ustr::from("BTC"),
1371 side: HyperliquidSide::Sell,
1372 px: dec!(50000.0),
1373 sz: dec!(0.5),
1374 time: 1_769_916_000_000,
1375 tid: 987_654_321,
1376 };
1377
1378 let tick = parse_recent_trade(&trade, &instrument).unwrap();
1379
1380 assert_eq!(tick.instrument_id, instrument.id());
1381 assert_eq!(tick.price.as_decimal(), dec!(50000));
1382 assert_eq!(tick.size.as_decimal(), dec!(0.5));
1383 assert_eq!(tick.aggressor_side, AggressorSide::Seller);
1384 assert_eq!(tick.trade_id.to_string(), "987654321");
1385 assert_eq!(
1386 tick.ts_event,
1387 UnixNanos::from(1_769_916_000_000 * 1_000_000)
1388 );
1389 assert_eq!(tick.ts_init, tick.ts_event);
1392 }
1393
1394 #[rstest]
1395 fn test_recent_trade_rejects_invalid_price() {
1396 let json = r#"{"coin":"BTC","side":"B","px":"not-a-number","sz":"0.5","time":1769916000000,"tid":1}"#;
1399 assert!(serde_json::from_str::<HyperliquidRecentTrade>(json).is_err());
1400 }
1401
1402 #[rstest]
1403 fn test_create_instrument_from_def_perp_sets_min_notional() {
1404 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1405 let defs = parse_perp_instruments(&meta, 0).unwrap();
1406
1407 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1408
1409 match instrument {
1410 InstrumentAny::CryptoPerpetual(perp) => {
1411 let min_notional = perp.min_notional.unwrap();
1412 assert_eq!(min_notional.currency, Currency::USD());
1413 assert_eq!(min_notional.as_decimal(), dec!(10));
1414 assert_eq!(perp.settlement_currency.code.as_str(), "USDC");
1415 }
1416 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1417 }
1418 }
1419
1420 #[rstest]
1421 fn test_parse_perp_instruments_with_non_usdc_collateral() {
1422 let all_metas: Vec<PerpMeta> =
1423 load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1424 let spot_meta: SpotMeta = load_test_data("http_spot_meta_non_usdc_collateral.json");
1425
1426 assert_eq!(all_metas[1].collateral_token, Some(360));
1427 assert_eq!(all_metas[2].collateral_token, Some(235));
1428
1429 let settlement_currency =
1430 resolve_perp_settlement_currency(&all_metas[1], Some(&spot_meta)).unwrap();
1431 let defs = parse_perp_instruments_with_settlement(
1432 &all_metas[1],
1433 110_000,
1434 settlement_currency.as_str(),
1435 );
1436
1437 assert_eq!(settlement_currency.as_str(), "USDH");
1438 assert_eq!(defs.len(), 1);
1439 assert_eq!(defs[0].symbol.as_str(), "km:US500-USD-PERP");
1440 assert_eq!(defs[0].quote.as_str(), "USD");
1441 assert_eq!(defs[0].settlement.as_ref().unwrap().as_str(), "USDH");
1442
1443 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1444 match instrument {
1445 InstrumentAny::CryptoPerpetual(perp) => {
1446 assert_eq!(perp.quote_currency.code.as_str(), "USD");
1447 assert_eq!(perp.settlement_currency.code.as_str(), "USDH");
1448 assert_eq!(perp.settlement_currency.name.as_str(), "Hyperliquid USD");
1449 }
1450 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1451 }
1452
1453 let settlement_currency =
1454 resolve_perp_settlement_currency(&all_metas[2], Some(&spot_meta)).unwrap();
1455 let defs = parse_perp_instruments_with_settlement(
1456 &all_metas[2],
1457 140_000,
1458 settlement_currency.as_str(),
1459 );
1460
1461 assert_eq!(settlement_currency.as_str(), "USDE");
1462 assert_eq!(defs.len(), 1);
1463 assert_eq!(defs[0].symbol.as_str(), "hyna:BTC-USD-PERP");
1464 assert_eq!(defs[0].quote.as_str(), "USD");
1465 assert_eq!(defs[0].settlement.as_ref().unwrap().as_str(), "USDE");
1466
1467 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1468 match instrument {
1469 InstrumentAny::CryptoPerpetual(perp) => {
1470 assert_eq!(perp.quote_currency.code.as_str(), "USD");
1471 assert_eq!(perp.settlement_currency.code.as_str(), "USDE");
1472 }
1473 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1474 }
1475 }
1476
1477 #[rstest]
1478 fn test_create_instrument_from_def_perp_defaults_missing_settlement_to_usdc() {
1479 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1480 let mut defs = parse_perp_instruments(&meta, 0).unwrap();
1481 defs[0].settlement = None;
1482
1483 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1484
1485 match instrument {
1486 InstrumentAny::CryptoPerpetual(perp) => {
1487 assert_eq!(perp.quote_currency.code.as_str(), "USD");
1488 assert_eq!(perp.settlement_currency.code.as_str(), "USDC");
1489 }
1490 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1491 }
1492 }
1493
1494 #[rstest]
1495 fn test_resolve_perp_settlement_currency_defaults_to_usdc() {
1496 let legacy_meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1497 let all_metas: Vec<PerpMeta> =
1498 load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1499
1500 let legacy_settlement = resolve_perp_settlement_currency(&legacy_meta, None).unwrap();
1501 let token_zero_settlement = resolve_perp_settlement_currency(&all_metas[0], None).unwrap();
1502
1503 assert_eq!(legacy_settlement.as_str(), "USDC");
1504 assert_eq!(token_zero_settlement.as_str(), "USDC");
1505 }
1506
1507 #[rstest]
1508 fn test_resolve_perp_settlement_currency_requires_spot_meta_for_non_usdc() {
1509 let all_metas: Vec<PerpMeta> =
1510 load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1511
1512 let err = resolve_perp_settlement_currency(&all_metas[1], None).unwrap_err();
1513
1514 assert_eq!(
1515 err,
1516 "Spot metadata required to resolve perp collateral token 360",
1517 );
1518 }
1519
1520 #[rstest]
1521 fn test_resolve_perp_settlement_currency_errors_on_missing_token_index() {
1522 let all_metas: Vec<PerpMeta> =
1523 load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1524 let spot_meta = SpotMeta {
1525 tokens: Vec::new(),
1526 universe: Vec::new(),
1527 };
1528
1529 let err = resolve_perp_settlement_currency(&all_metas[1], Some(&spot_meta)).unwrap_err();
1530
1531 assert_eq!(
1532 err,
1533 "Perp collateral token index 360 not found in spot metadata",
1534 );
1535 }
1536
1537 #[rstest]
1538 fn test_deserialize_l2_book_from_real_data() {
1539 let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
1540
1541 assert_eq!(book.coin, "BTC");
1543 assert_eq!(book.levels.len(), 2); assert_eq!(book.levels[0].len(), 5); assert_eq!(book.levels[1].len(), 5); let bids = &book.levels[0];
1549 let asks = &book.levels[1];
1550
1551 for i in 1..bids.len() {
1553 let prev_price = bids[i - 1].px;
1554 let curr_price = bids[i].px;
1555 assert!(prev_price >= curr_price, "Bids should be descending");
1556 }
1557
1558 for i in 1..asks.len() {
1560 let prev_price = asks[i - 1].px;
1561 let curr_price = asks[i].px;
1562 assert!(prev_price <= curr_price, "Asks should be ascending");
1563 }
1564 }
1565
1566 #[rstest]
1567 fn test_parse_spot_instruments() {
1568 let tokens = vec![
1569 SpotToken {
1570 name: "USDC".to_string(),
1571 sz_decimals: 6,
1572 wei_decimals: 6,
1573 index: 0,
1574 token_id: "0x1".to_string(),
1575 is_canonical: true,
1576 evm_contract: None,
1577 full_name: None,
1578 deployer_trading_fee_share: None,
1579 },
1580 SpotToken {
1581 name: "PURR".to_string(),
1582 sz_decimals: 0,
1583 wei_decimals: 5,
1584 index: 1,
1585 token_id: "0x2".to_string(),
1586 is_canonical: true,
1587 evm_contract: None,
1588 full_name: None,
1589 deployer_trading_fee_share: None,
1590 },
1591 ];
1592
1593 let pairs = vec![
1594 SpotPair {
1595 name: "PURR/USDC".to_string(),
1596 tokens: [1, 0], index: 0,
1598 is_canonical: true,
1599 },
1600 SpotPair {
1601 name: "ALIAS".to_string(),
1602 tokens: [1, 0],
1603 index: 1,
1604 is_canonical: false, },
1606 ];
1607
1608 let meta = SpotMeta {
1609 tokens,
1610 universe: pairs,
1611 };
1612
1613 let defs = parse_spot_instruments(&meta).unwrap();
1614
1615 assert_eq!(defs.len(), 2);
1617
1618 let purr_usdc = &defs[0];
1619 assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
1620 assert_eq!(purr_usdc.base, "PURR");
1621 assert_eq!(purr_usdc.quote, "USDC");
1622 assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
1623 assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
1625 assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
1626 assert_eq!(purr_usdc.lot_size, dec!(1));
1627 assert_eq!(purr_usdc.max_leverage, None);
1628 assert!(!purr_usdc.only_isolated);
1629 assert!(purr_usdc.active);
1630
1631 let alias = &defs[1];
1632 assert_eq!(alias.symbol, "PURR-USDC-SPOT");
1633 assert_eq!(alias.base, "PURR");
1634 assert!(!alias.active); let instrument = create_instrument_from_def(purr_usdc, UnixNanos::default()).unwrap();
1637
1638 match instrument {
1639 InstrumentAny::CurrencyPair(pair) => {
1640 let min_notional = pair.min_notional.unwrap();
1641 assert_eq!(min_notional.currency, Currency::USDC());
1642 assert_eq!(min_notional.as_decimal(), dec!(10));
1643 }
1644 other => panic!("Expected CurrencyPair, was {other:?}"),
1645 }
1646 }
1647
1648 #[rstest]
1649 fn test_parse_spot_instruments_sorts_canonical_before_non_canonical() {
1650 let tokens = vec![
1654 SpotToken {
1655 name: "USDC".to_string(),
1656 sz_decimals: 6,
1657 wei_decimals: 6,
1658 index: 0,
1659 token_id: "0x1".to_string(),
1660 is_canonical: true,
1661 evm_contract: None,
1662 full_name: None,
1663 deployer_trading_fee_share: None,
1664 },
1665 SpotToken {
1666 name: "HYPE".to_string(),
1667 sz_decimals: 2,
1668 wei_decimals: 8,
1669 index: 150,
1670 token_id: "0x2".to_string(),
1671 is_canonical: true,
1672 evm_contract: None,
1673 full_name: None,
1674 deployer_trading_fee_share: None,
1675 },
1676 ];
1677
1678 let pairs = vec![
1679 SpotPair {
1680 name: "HYPE_OLD".to_string(),
1681 tokens: [150, 0],
1682 index: 3,
1683 is_canonical: false,
1684 },
1685 SpotPair {
1686 name: "HYPE".to_string(),
1687 tokens: [150, 0],
1688 index: 107,
1689 is_canonical: true,
1690 },
1691 ];
1692
1693 let defs = parse_spot_instruments(&SpotMeta {
1694 tokens,
1695 universe: pairs,
1696 })
1697 .unwrap();
1698
1699 assert_eq!(defs.len(), 2);
1700 assert!(defs[0].active, "canonical must sort first");
1701 assert_eq!(defs[0].asset_index, 10000 + 107);
1702 assert!(!defs[1].active);
1703 assert_eq!(defs[1].asset_index, 10000 + 3);
1704 }
1705
1706 #[rstest]
1707 fn test_price_decimals_clamping() {
1708 let meta = PerpMeta {
1709 universe: vec![PerpAsset {
1710 name: "HIGHPREC".to_string(),
1711 sz_decimals: 10, max_leverage: Some(1),
1713 ..Default::default()
1714 }],
1715 margin_tables: vec![],
1716 collateral_token: None,
1717 };
1718
1719 let defs = parse_perp_instruments(&meta, 0).unwrap();
1720 assert_eq!(defs[0].price_decimals, 0);
1721 assert_eq!(defs[0].tick_size, dec!(1));
1722 }
1723
1724 #[rstest]
1725 fn test_parse_perp_instruments_hip3_dex() {
1726 let meta = PerpMeta {
1728 universe: vec![
1729 PerpAsset {
1730 name: "xyz:TSLA".to_string(),
1731 sz_decimals: 3,
1732 max_leverage: Some(10),
1733 only_isolated: None,
1734 is_delisted: None,
1735 growth_mode: Some("enabled".to_string()),
1736 margin_mode: Some("strictIsolated".to_string()),
1737 },
1738 PerpAsset {
1739 name: "xyz:NVDA".to_string(),
1740 sz_decimals: 3,
1741 max_leverage: Some(20),
1742 only_isolated: None,
1743 is_delisted: None,
1744 growth_mode: None,
1745 margin_mode: None,
1746 },
1747 ],
1748 margin_tables: vec![],
1749 collateral_token: None,
1750 };
1751
1752 let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1753 assert_eq!(defs.len(), 2);
1754
1755 assert_eq!(defs[0].symbol, "xyz:TSLA-USD-PERP");
1757 assert!(defs[0].symbol.contains(':'));
1758 assert_eq!(defs[0].base, "xyz:TSLA");
1759 assert_eq!(defs[0].asset_index, 110_000);
1760 assert!(defs[0].active);
1761
1762 assert_eq!(defs[1].symbol, "xyz:NVDA-USD-PERP");
1763 assert_eq!(defs[1].asset_index, 110_001);
1764 }
1765
1766 #[rstest]
1767 #[case("BTC", "BTC")]
1768 #[case("kPEPE", "kPEPE")]
1769 #[case("xyz:TSLA", "xyz:TSLA")]
1770 #[case("dex:STREAMABCD****", "dex:STREAMABCDxxxx")]
1771 #[case("ABC?", "ABCx")]
1772 #[case("a*b?c", "axbxc")]
1773 fn test_sanitize_symbol(#[case] input: &str, #[case] expected: &str) {
1774 assert_eq!(sanitize_symbol(input), expected);
1775 }
1776
1777 #[rstest]
1778 fn test_parse_spot_instruments_sanitizes_wildcard_token_names() {
1779 let tokens = vec![
1783 SpotToken {
1784 name: "USDC".to_string(),
1785 sz_decimals: 6,
1786 wei_decimals: 6,
1787 index: 0,
1788 token_id: "0x1".to_string(),
1789 is_canonical: true,
1790 evm_contract: None,
1791 full_name: None,
1792 deployer_trading_fee_share: None,
1793 },
1794 SpotToken {
1795 name: "ABC?".to_string(),
1796 sz_decimals: 4,
1797 wei_decimals: 4,
1798 index: 1,
1799 token_id: "0x2".to_string(),
1800 is_canonical: true,
1801 evm_contract: None,
1802 full_name: None,
1803 deployer_trading_fee_share: None,
1804 },
1805 ];
1806
1807 let pairs = vec![SpotPair {
1808 name: "ABC?/USDC".to_string(),
1809 tokens: [1, 0],
1810 index: 50,
1811 is_canonical: true,
1812 }];
1813
1814 let meta = SpotMeta {
1815 tokens,
1816 universe: pairs,
1817 };
1818
1819 let defs = parse_spot_instruments(&meta).unwrap();
1820 assert_eq!(defs.len(), 1);
1821 assert_eq!(defs[0].symbol, "ABCx-USDC-SPOT");
1822 assert_eq!(defs[0].base, "ABC?");
1823 assert_eq!(defs[0].quote, "USDC");
1824 }
1825
1826 #[rstest]
1827 fn test_parse_perp_instruments_sanitizes_hip3_wildcards() {
1828 let meta = PerpMeta {
1829 universe: vec![PerpAsset {
1830 name: "dex:STREAMABCD****".to_string(),
1831 sz_decimals: 3,
1832 max_leverage: Some(10),
1833 only_isolated: None,
1834 is_delisted: None,
1835 growth_mode: None,
1836 margin_mode: None,
1837 }],
1838 margin_tables: vec![],
1839 collateral_token: None,
1840 };
1841
1842 let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1843 assert_eq!(defs.len(), 1);
1844 assert_eq!(defs[0].symbol, "dex:STREAMABCDxxxx-USD-PERP");
1845 assert_eq!(defs[0].raw_symbol.as_str(), "dex:STREAMABCD****");
1846 assert_eq!(defs[0].base.as_str(), "dex:STREAMABCD****");
1847 }
1848
1849 #[rstest]
1850 fn test_parse_outcome_instruments_emits_both_sides() {
1851 let meta = OutcomeMeta {
1852 outcomes: vec![OutcomeMarket {
1853 outcome: 1,
1854 name: "BTC daily".to_string(),
1855 description: "BTC settles above strike at 06:00 UTC".to_string(),
1856 side_specs: vec![
1857 OutcomeSideSpec {
1858 name: "Yes".to_string(),
1859 },
1860 OutcomeSideSpec {
1861 name: "No".to_string(),
1862 },
1863 ],
1864 }],
1865 questions: vec![],
1866 };
1867
1868 let defs = parse_outcome_instruments(&meta).unwrap();
1869 assert_eq!(defs.len(), 2);
1870
1871 let yes = &defs[0];
1872 assert_eq!(yes.symbol.as_str(), "1-YES-OUTCOME");
1873 assert_eq!(yes.raw_symbol.as_str(), "#10");
1874 assert_eq!(yes.market_type, HyperliquidMarketType::Outcome);
1875 assert_eq!(yes.asset_index, 100_000_010);
1876 assert_eq!(yes.price_decimals, OUTCOME_PRICE_DECIMALS);
1877 assert_eq!(yes.size_decimals, OUTCOME_SIZE_DECIMALS);
1878 assert_eq!(yes.tick_size, dec!(0.0001));
1879 assert_eq!(yes.lot_size, dec!(0.01));
1880 assert_eq!(yes.quote.as_str(), "USDH");
1881 assert!(yes.active);
1882
1883 let yes_meta = yes.outcome.as_ref().unwrap();
1884 assert_eq!(yes_meta.outcome_index, 1);
1885 assert_eq!(yes_meta.outcome_side, 0);
1886 assert_eq!(yes_meta.market_name.as_str(), "BTC daily");
1887 assert_eq!(yes_meta.side_name.unwrap().as_str(), "Yes");
1888 assert_eq!(
1889 yes_meta.description.unwrap().as_str(),
1890 "BTC settles above strike at 06:00 UTC"
1891 );
1892
1893 let no = &defs[1];
1894 assert_eq!(no.symbol.as_str(), "1-NO-OUTCOME");
1895 assert_eq!(no.raw_symbol.as_str(), "#11");
1896 assert_eq!(no.asset_index, 100_000_011);
1897 let no_meta = no.outcome.as_ref().unwrap();
1898 assert_eq!(no_meta.outcome_side, 1);
1899 assert_eq!(no_meta.side_name.unwrap().as_str(), "No");
1900 }
1901
1902 #[rstest]
1903 fn test_parse_outcome_instruments_handles_missing_side_specs() {
1904 let meta = OutcomeMeta {
1905 outcomes: vec![OutcomeMarket {
1906 outcome: 5,
1907 name: "Recurring".to_string(),
1908 description: String::new(),
1909 side_specs: vec![],
1910 }],
1911 questions: vec![],
1912 };
1913
1914 let defs = parse_outcome_instruments(&meta).unwrap();
1915 assert_eq!(defs.len(), 2);
1916
1917 assert_eq!(
1921 defs[0]
1922 .outcome
1923 .as_ref()
1924 .unwrap()
1925 .side_name
1926 .unwrap()
1927 .as_str(),
1928 "Yes"
1929 );
1930 assert_eq!(
1931 defs[1]
1932 .outcome
1933 .as_ref()
1934 .unwrap()
1935 .side_name
1936 .unwrap()
1937 .as_str(),
1938 "No"
1939 );
1940
1941 for def in &defs {
1942 assert!(def.outcome.as_ref().unwrap().description.is_none());
1943 }
1944
1945 assert_eq!(defs[0].asset_index, 100_000_050);
1946 assert_eq!(defs[1].asset_index, 100_000_051);
1947 }
1948
1949 #[rstest]
1950 fn test_get_usdh_currency_registers_with_explicit_precision() {
1951 let currency = get_usdh_currency();
1952 assert_eq!(currency.code.as_str(), "USDH");
1953 assert_eq!(currency.precision, 8);
1954 assert_eq!(currency.currency_type, CurrencyType::Crypto);
1955
1956 let again = get_usdh_currency();
1958 assert_eq!(again, currency);
1959 assert!(Currency::try_from_str("USDH").is_some());
1960 }
1961
1962 #[rstest]
1963 fn test_create_instrument_from_def_outcome_emits_binary_option() {
1964 let meta = OutcomeMeta {
1965 outcomes: vec![OutcomeMarket {
1966 outcome: 2,
1967 name: "Recurring BTC".to_string(),
1968 description: "Daily settlement".to_string(),
1969 side_specs: vec![
1970 OutcomeSideSpec {
1971 name: "Yes".to_string(),
1972 },
1973 OutcomeSideSpec {
1974 name: "No".to_string(),
1975 },
1976 ],
1977 }],
1978 questions: vec![],
1979 };
1980
1981 let defs = parse_outcome_instruments(&meta).unwrap();
1982 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1983
1984 match instrument {
1985 InstrumentAny::BinaryOption(bo) => {
1986 assert_eq!(bo.id.symbol.as_str(), "2-YES-OUTCOME");
1987 assert_eq!(bo.raw_symbol.as_str(), "#20");
1988 assert_eq!(bo.asset_class, AssetClass::Alternative);
1989 assert_eq!(bo.currency.code.as_str(), "USDH");
1990 assert_eq!(bo.price_precision, OUTCOME_PRICE_DECIMALS as u8);
1991 assert_eq!(bo.size_precision, OUTCOME_SIZE_DECIMALS as u8);
1992 assert_eq!(bo.outcome.unwrap().as_str(), "Yes");
1993 assert_eq!(bo.description.unwrap().as_str(), "Daily settlement");
1994
1995 let info = bo.info.expect("info should be populated for outcomes");
1996 assert_eq!(info.get_u64("outcome_index"), Some(2));
1997 assert_eq!(info.get_u64("outcome_side"), Some(0));
1998 assert_eq!(info.get_u64("encoding"), Some(20));
1999 assert_eq!(info.get_u64("asset_id"), Some(100_000_020));
2000 assert_eq!(info.get_str("side_name"), Some("Yes"));
2001 assert_eq!(info.get_str("market_name"), Some("Recurring BTC"));
2002 }
2003 other => panic!("Expected BinaryOption, was {other:?}"),
2004 }
2005 }
2006
2007 #[rstest]
2008 fn test_create_instrument_from_def_outcome_info_carries_parsed_description() {
2009 let meta = OutcomeMeta {
2010 outcomes: vec![OutcomeMarket {
2011 outcome: 5,
2012 name: "Recurring BTC".to_string(),
2013 description:
2014 "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
2015 .to_string(),
2016 side_specs: vec![
2017 OutcomeSideSpec {
2018 name: "Yes".to_string(),
2019 },
2020 OutcomeSideSpec {
2021 name: "No".to_string(),
2022 },
2023 ],
2024 }],
2025 questions: vec![],
2026 };
2027
2028 let defs = parse_outcome_instruments(&meta).unwrap();
2029 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2030
2031 match yes {
2032 InstrumentAny::BinaryOption(bo) => {
2033 let info = bo.info.expect("info should be populated for outcomes");
2034 assert_eq!(info.get_str("class"), Some("priceBinary"));
2035 assert_eq!(info.get_str("underlying"), Some("BTC"));
2036 assert_eq!(info.get_str("expiry"), Some("20260508-0600"));
2037 assert_eq!(info.get_str("target_price"), Some("81041"));
2038 assert_eq!(info.get_str("period"), Some("1d"));
2039 assert!(info.get("question").is_none());
2040 }
2041 other => panic!("Expected BinaryOption, was {other:?}"),
2042 }
2043 }
2044
2045 #[rstest]
2046 fn test_create_instrument_from_def_outcome_info_merges_parent_question() {
2047 let meta = OutcomeMeta {
2048 outcomes: vec![
2049 OutcomeMarket {
2050 outcome: 6,
2051 name: "Recurring Fallback".to_string(),
2052 description: "other".to_string(),
2053 side_specs: vec![],
2054 },
2055 OutcomeMarket {
2056 outcome: 7,
2057 name: "Recurring Named Outcome".to_string(),
2058 description: "index:0".to_string(),
2059 side_specs: vec![],
2060 },
2061 ],
2062 questions: vec![OutcomeQuestion {
2063 question: 0,
2064 name: "Recurring".to_string(),
2065 description:
2066 "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
2067 .to_string(),
2068 fallback_outcome: Some(6),
2069 named_outcomes: vec![7, 8, 9],
2070 settled_named_outcomes: vec![],
2071 }],
2072 };
2073
2074 let defs = parse_outcome_instruments(&meta).unwrap();
2075
2076 let named = create_instrument_from_def(&defs[2], UnixNanos::default()).unwrap();
2078 match named {
2079 InstrumentAny::BinaryOption(bo) => {
2080 assert_eq!(bo.id.symbol.as_str(), "7-YES-OUTCOME");
2081 let info = bo.info.expect("info should be populated for outcomes");
2082 assert_eq!(info.get_u64("named_index"), Some(0));
2083 assert_eq!(info.get_u64("question"), Some(0));
2084 assert_eq!(info.get_str("question_name"), Some("Recurring"));
2085 assert_eq!(info.get_str("question_class"), Some("priceBucket"));
2086 assert_eq!(info.get_str("question_underlying"), Some("BTC"));
2087 assert_eq!(
2088 info.get_str("question_price_thresholds"),
2089 Some("79303,82540"),
2090 );
2091 assert_eq!(info.get_str("question_expiry"), Some("20260508-0600"));
2092 }
2093 other => panic!("Expected BinaryOption, was {other:?}"),
2094 }
2095
2096 let fallback = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2098 match fallback {
2099 InstrumentAny::BinaryOption(bo) => {
2100 assert_eq!(bo.id.symbol.as_str(), "6-YES-OUTCOME");
2101 let info = bo.info.expect("info should be populated for outcomes");
2102 assert_eq!(info.get_bool("is_fallback"), Some(true));
2103 assert_eq!(info.get_u64("question"), Some(0));
2104 assert_eq!(info.get_str("question_class"), Some("priceBucket"));
2105 }
2106 other => panic!("Expected BinaryOption, was {other:?}"),
2107 }
2108 }
2109
2110 #[rstest]
2111 fn test_parse_fill_report_outcome_round_trip() {
2112 let meta = OutcomeMeta {
2113 outcomes: vec![OutcomeMarket {
2114 outcome: 42,
2115 name: "BTC daily".to_string(),
2116 description: "BTC settles above strike at 06:00 UTC".to_string(),
2117 side_specs: vec![
2118 OutcomeSideSpec {
2119 name: "Yes".to_string(),
2120 },
2121 OutcomeSideSpec {
2122 name: "No".to_string(),
2123 },
2124 ],
2125 }],
2126 questions: vec![],
2127 };
2128
2129 let defs = parse_outcome_instruments(&meta).unwrap();
2130 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2131 assert_eq!(yes.id().symbol.as_str(), "42-YES-OUTCOME");
2132
2133 let fill = HyperliquidFill {
2134 coin: Ustr::from("#420"),
2135 px: dec!(0.5500),
2136 sz: dec!(1000.00),
2137 side: HyperliquidSide::Buy,
2138 time: 1_704_470_400_000,
2139 start_position: dec!(0.00),
2140 dir: HyperliquidFillDirection::OpenLong,
2141 closed_pnl: dec!(0.0),
2142 hash: "0xfeed".to_string(),
2143 oid: 99_001,
2144 crossed: true,
2145 fee: dec!(0.0),
2146 fee_token: Ustr::from("+420"),
2147 };
2148
2149 let account_id = AccountId::from("HYPERLIQUID-001");
2150 let report = parse_fill_report(&fill, &yes, account_id, UnixNanos::default()).unwrap();
2151
2152 assert_eq!(report.commission.currency.code.as_str(), "USDH");
2156 assert!(report.commission.as_decimal().is_zero());
2157 assert_eq!(report.order_side, OrderSide::Buy);
2158 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
2159 assert_eq!(report.last_qty.as_decimal(), dec!(1000));
2160 assert_eq!(report.last_px.as_decimal(), dec!(0.55));
2161 }
2162
2163 #[rstest]
2164 fn test_deserialize_user_fills_with_dust_conversion() {
2165 let fills: Vec<HyperliquidFill> = load_test_data("http_user_fills_dust_conversion.json");
2168
2169 let dirs: Vec<HyperliquidFillDirection> = fills.iter().map(|f| f.dir).collect();
2170
2171 assert_eq!(
2172 dirs,
2173 vec![
2174 HyperliquidFillDirection::OpenLong,
2175 HyperliquidFillDirection::CloseShort,
2176 HyperliquidFillDirection::Buy,
2177 HyperliquidFillDirection::SpotDustConversion,
2178 HyperliquidFillDirection::NetChildVaults,
2179 ],
2180 );
2181 }
2182
2183 #[rstest]
2184 fn test_resolve_fee_currency_outcome_token_returns_quote_even_when_registered() {
2185 let meta = OutcomeMeta {
2186 outcomes: vec![OutcomeMarket {
2187 outcome: 88,
2188 name: "Edge".to_string(),
2189 description: String::new(),
2190 side_specs: vec![],
2191 }],
2192 questions: vec![],
2193 };
2194 let defs = parse_outcome_instruments(&meta).unwrap();
2195 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2196
2197 let _ = get_currency("+880");
2200 assert!(Currency::try_from_str("+880").is_some());
2201
2202 let currency = resolve_fee_currency("+880", Decimal::ZERO, &yes)
2203 .expect("zero-fee outcome side token must resolve to quote currency");
2204 assert_eq!(currency.code.as_str(), "USDH");
2205
2206 let err = resolve_fee_currency("+880", dec!(0.01), &yes).unwrap_err();
2207 let err_msg = err.to_string();
2208 assert!(err_msg.contains("Outcome side token '+880'"));
2209 assert!(err_msg.contains("non-zero fee"));
2210 }
2211
2212 #[rstest]
2213 #[case("+50", true)]
2214 #[case("+0", true)]
2215 #[case("+880", true)]
2216 #[case("", false)]
2217 #[case("+", false)]
2218 #[case("+abc", false)]
2219 #[case("+50a", false)]
2220 #[case("#50", false)]
2221 #[case("USDC", false)]
2222 #[case("-50", false)]
2223 fn test_is_outcome_side_token(#[case] input: &str, #[case] expected: bool) {
2224 assert_eq!(is_outcome_side_token(input), expected);
2225 }
2226
2227 #[rstest]
2228 fn test_resolve_fee_currency_falls_back_to_quote_when_unregistered_and_zero_fee() {
2229 let meta = OutcomeMeta {
2230 outcomes: vec![OutcomeMarket {
2231 outcome: 77,
2232 name: "Edge".to_string(),
2233 description: String::new(),
2234 side_specs: vec![],
2235 }],
2236 questions: vec![],
2237 };
2238
2239 let defs = parse_outcome_instruments(&meta).unwrap();
2240 let no = create_instrument_from_def(&defs[1], UnixNanos::default()).unwrap();
2241
2242 let currency = resolve_fee_currency("+UNREGISTERED-TOKEN", Decimal::ZERO, &no)
2245 .expect("zero-fee fallback should succeed");
2246 assert_eq!(currency.code.as_str(), "USDH");
2247
2248 let err = resolve_fee_currency("+UNREGISTERED-TOKEN", dec!(0.01), &no).unwrap_err();
2249 assert!(err.to_string().contains("non-zero fee"));
2250 }
2251
2252 #[rstest]
2253 fn test_parse_outcome_expiry_ns_round_trip() {
2254 let ns = parse_outcome_expiry_ns("20260508-0600").unwrap();
2256 assert_eq!(ns.as_u64(), 1_778_220_000_000_000_000);
2257 }
2258
2259 #[rstest]
2260 #[case("")]
2261 #[case("20260508")]
2262 #[case("20260508-")]
2263 #[case("20260508-0600 ")]
2264 #[case("2026-05-08-06-00")]
2265 #[case("20261308-0600")]
2266 fn test_parse_outcome_expiry_ns_rejects_bad_input(#[case] input: &str) {
2267 assert!(parse_outcome_expiry_ns(input).is_none());
2268 }
2269
2270 #[rstest]
2271 fn test_parse_outcome_instruments_pulls_expiry_from_price_binary() {
2272 let meta = OutcomeMeta {
2273 outcomes: vec![OutcomeMarket {
2274 outcome: 5,
2275 name: "Recurring".to_string(),
2276 description:
2277 "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
2278 .to_string(),
2279 side_specs: vec![
2280 OutcomeSideSpec {
2281 name: "Yes".to_string(),
2282 },
2283 OutcomeSideSpec {
2284 name: "No".to_string(),
2285 },
2286 ],
2287 }],
2288 questions: vec![],
2289 };
2290
2291 let defs = parse_outcome_instruments(&meta).unwrap();
2292 let yes_meta = defs[0].outcome.as_ref().unwrap();
2293 assert_eq!(yes_meta.expiration_ns.as_u64(), 1_778_220_000_000_000_000);
2294 }
2295
2296 #[rstest]
2297 fn test_parse_outcome_instruments_inherits_expiry_from_parent_question() {
2298 let meta = OutcomeMeta {
2302 outcomes: vec![
2303 OutcomeMarket {
2304 outcome: 6,
2305 name: "Recurring Fallback".to_string(),
2306 description: "other".to_string(),
2307 side_specs: vec![],
2308 },
2309 OutcomeMarket {
2310 outcome: 7,
2311 name: "Recurring Named Outcome".to_string(),
2312 description: "index:0".to_string(),
2313 side_specs: vec![],
2314 },
2315 ],
2316 questions: vec![OutcomeQuestion {
2317 question: 0,
2318 name: "Recurring".to_string(),
2319 description:
2320 "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
2321 .to_string(),
2322 fallback_outcome: Some(6),
2323 named_outcomes: vec![7, 8, 9],
2324 settled_named_outcomes: vec![],
2325 }],
2326 };
2327
2328 let defs = parse_outcome_instruments(&meta).unwrap();
2329 let expected_ns: u64 = 1_778_220_000_000_000_000;
2330
2331 for def in &defs {
2332 let outcome = def.outcome.as_ref().unwrap();
2333 assert_eq!(
2334 outcome.expiration_ns.as_u64(),
2335 expected_ns,
2336 "outcome {} side {} should inherit expiry",
2337 outcome.outcome_index,
2338 outcome.outcome_side,
2339 );
2340 }
2341 }
2342
2343 #[rstest]
2344 fn test_derive_outcome_settlements_returns_empty_when_no_questions() {
2345 let meta = OutcomeMeta {
2346 outcomes: vec![],
2347 questions: vec![],
2348 };
2349 assert!(derive_outcome_settlements(&meta).is_empty());
2350 }
2351
2352 #[rstest]
2353 fn test_derive_outcome_settlements_returns_empty_when_no_questions_settled() {
2354 let meta = OutcomeMeta {
2355 outcomes: vec![],
2356 questions: vec![OutcomeQuestion {
2357 question: 0,
2358 name: "Recurring".to_string(),
2359 description: "class:priceBucket|expiry:20260508-0600".to_string(),
2360 fallback_outcome: Some(6),
2361 named_outcomes: vec![7, 8, 9],
2362 settled_named_outcomes: vec![],
2363 }],
2364 };
2365
2366 assert!(derive_outcome_settlements(&meta).is_empty());
2367 }
2368
2369 #[rstest]
2370 fn test_derive_outcome_settlements_marks_winners_losers_and_fallback() {
2371 let meta = OutcomeMeta {
2372 outcomes: vec![],
2373 questions: vec![OutcomeQuestion {
2374 question: 0,
2375 name: "Recurring".to_string(),
2376 description: "class:priceBucket|expiry:20260508-0600".to_string(),
2377 fallback_outcome: Some(6),
2378 named_outcomes: vec![7, 8, 9],
2379 settled_named_outcomes: vec![8],
2380 }],
2381 };
2382
2383 let settlements = derive_outcome_settlements(&meta);
2384 let lookup: ahash::AHashMap<(u32, u8), u8> = settlements
2385 .into_iter()
2386 .map(|s| ((s.outcome_index, s.outcome_side), s.final_value))
2387 .collect();
2388
2389 assert_eq!(lookup[&(8, 0)], 1);
2391 assert_eq!(lookup[&(8, 1)], 0);
2392
2393 for losing in [7, 9, 6] {
2395 assert_eq!(lookup[&(losing, 0)], 0, "outcome {losing} Yes side");
2396 assert_eq!(lookup[&(losing, 1)], 1, "outcome {losing} No side");
2397 }
2398
2399 assert_eq!(lookup.len(), 8);
2400 }
2401
2402 #[rstest]
2403 fn test_parse_outcome_meta_question_settlement_round_trip() {
2404 let json = r#"{
2405 "outcomes": [{"outcome": 5, "name": "Recurring", "description": "class:priceBinary|expiry:20260508-0600", "sideSpecs": []}],
2406 "questions": [{
2407 "question": 0,
2408 "name": "Recurring",
2409 "description": "class:priceBucket|expiry:20260508-0600",
2410 "fallbackOutcome": 6,
2411 "namedOutcomes": [7, 8, 9],
2412 "settledNamedOutcomes": [8]
2413 }]
2414 }"#;
2415
2416 let meta: OutcomeMeta = serde_json::from_str(json).unwrap();
2417 assert_eq!(meta.questions.len(), 1);
2418 let q = &meta.questions[0];
2419 assert_eq!(q.fallback_outcome, Some(6));
2420 assert_eq!(q.named_outcomes, vec![7, 8, 9]);
2421 assert_eq!(q.settled_named_outcomes, vec![8]);
2422
2423 assert!(meta.parent_question(7).is_some());
2424 assert!(meta.parent_question(6).is_some());
2425 assert!(meta.parent_question(99).is_none());
2426 }
2427}