1use anyhow::Context;
17use nautilus_core::{UUID4, UnixNanos};
18use nautilus_model::{
19 enums::{
20 AssetClass, CurrencyType, LiquiditySide, OrderSide, OrderStatus, OrderType,
21 PositionSideSpecified, TimeInForce, TriggerType,
22 },
23 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
24 instruments::{BinaryOption, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
25 reports::{FillReport, OrderStatusReport, PositionStatusReport},
26 types::{Currency, Money, Price, Quantity},
27};
28use rust_decimal::Decimal;
29use serde::{Deserialize, Serialize};
30use ustr::Ustr;
31
32use super::models::{
33 AssetPosition, HyperliquidFill, OutcomeMarket, OutcomeMeta, PerpMeta, SpotBalance, SpotMeta,
34};
35use crate::{
36 common::{
37 consts::HYPERLIQUID_VENUE,
38 enums::{
39 HyperliquidFillDirection, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
40 HyperliquidSide, HyperliquidTpSl,
41 },
42 parse::make_fill_trade_id,
43 types::HyperliquidAssetId,
44 },
45 websocket::messages::{WsBasicOrderData, WsOrderData},
46};
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum HyperliquidMarketType {
51 Perp,
53 Spot,
55 Outcome,
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct HyperliquidOutcomeMetadata {
67 pub outcome_index: u32,
69 pub outcome_side: u8,
71 pub market_name: Ustr,
73 pub side_name: Option<Ustr>,
76 pub description: Option<Ustr>,
78 pub activation_ns: UnixNanos,
80 pub expiration_ns: UnixNanos,
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct HyperliquidInstrumentDef {
90 pub symbol: Ustr,
92 pub raw_symbol: Ustr,
97 pub base: Ustr,
99 pub quote: Ustr,
101 pub market_type: HyperliquidMarketType,
103 pub asset_index: u32,
108 pub price_decimals: u32,
110 pub size_decimals: u32,
112 pub tick_size: Decimal,
114 pub lot_size: Decimal,
116 pub max_leverage: Option<u32>,
118 pub only_isolated: bool,
120 pub is_hip3: bool,
122 pub active: bool,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub outcome: Option<HyperliquidOutcomeMetadata>,
128 pub raw_data: String,
130}
131
132#[must_use]
139fn sanitize_symbol(value: &str) -> std::borrow::Cow<'_, str> {
140 if value.bytes().any(|b| b == b'*' || b == b'?') {
141 let mut out = String::with_capacity(value.len());
142 for ch in value.chars() {
143 out.push(if ch == '*' || ch == '?' { 'x' } else { ch });
144 }
145 std::borrow::Cow::Owned(out)
146 } else {
147 std::borrow::Cow::Borrowed(value)
148 }
149}
150
151pub fn parse_perp_instruments(
165 meta: &PerpMeta,
166 asset_index_base: u32,
167) -> Result<Vec<HyperliquidInstrumentDef>, String> {
168 const PERP_MAX_DECIMALS: i32 = 6;
169
170 let mut defs = Vec::new();
171
172 for (index, asset) in meta.universe.iter().enumerate() {
173 let is_delisted = asset.is_delisted.unwrap_or(false);
174
175 let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
176 let tick_size = pow10_neg(price_decimals);
177 let lot_size = pow10_neg(asset.sz_decimals);
178
179 let symbol = format!("{}-USD-PERP", sanitize_symbol(&asset.name));
180
181 let raw_symbol: Ustr = asset.name.as_str().into();
182
183 let def = HyperliquidInstrumentDef {
184 symbol: symbol.into(),
185 raw_symbol,
186 base: asset.name.clone().into(),
187 quote: "USD".into(),
188 market_type: HyperliquidMarketType::Perp,
189 asset_index: asset_index_base + index as u32,
190 price_decimals,
191 size_decimals: asset.sz_decimals,
192 tick_size,
193 lot_size,
194 max_leverage: asset.max_leverage,
195 only_isolated: asset.only_isolated.unwrap_or(false),
196 is_hip3: asset_index_base > 0,
197 active: !is_delisted,
198 outcome: None,
199 raw_data: serde_json::to_string(asset).unwrap_or_default(),
200 };
201
202 defs.push(def);
203 }
204
205 Ok(defs)
206}
207
208pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
216 const SPOT_MAX_DECIMALS: i32 = 8; const SPOT_INDEX_OFFSET: u32 = 10000; let mut defs = Vec::new();
220
221 let mut tokens_by_index = ahash::AHashMap::new();
223 for token in &meta.tokens {
224 tokens_by_index.insert(token.index, token);
225 }
226
227 for pair in &meta.universe {
228 let base_token = tokens_by_index
232 .get(&pair.tokens[0])
233 .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
234 let quote_token = tokens_by_index
235 .get(&pair.tokens[1])
236 .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
237
238 let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
239 let tick_size = pow10_neg(price_decimals);
240 let lot_size = pow10_neg(base_token.sz_decimals);
241
242 let symbol = format!(
243 "{}-{}-SPOT",
244 sanitize_symbol(&base_token.name),
245 sanitize_symbol("e_token.name),
246 );
247
248 let raw_symbol: Ustr = if base_token.name == "PURR" {
252 pair.name.as_str().into()
253 } else {
254 format!("@{}", pair.index).into()
255 };
256
257 let def = HyperliquidInstrumentDef {
258 symbol: symbol.into(),
259 raw_symbol,
260 base: base_token.name.clone().into(),
261 quote: quote_token.name.clone().into(),
262 market_type: HyperliquidMarketType::Spot,
263 asset_index: SPOT_INDEX_OFFSET + pair.index,
264 price_decimals,
265 size_decimals: base_token.sz_decimals,
266 tick_size,
267 lot_size,
268 max_leverage: None,
269 only_isolated: false,
270 is_hip3: false,
271 active: pair.is_canonical, outcome: None,
273 raw_data: serde_json::to_string(pair).unwrap_or_default(),
274 };
275
276 defs.push(def);
277 }
278
279 defs.sort_by(|a, b| {
284 b.active
285 .cmp(&a.active)
286 .then(a.asset_index.cmp(&b.asset_index))
287 });
288
289 Ok(defs)
290}
291
292pub const OUTCOME_PRICE_DECIMALS: u32 = 4;
298pub const OUTCOME_SIZE_DECIMALS: u32 = 2;
299
300pub fn parse_outcome_instruments(
315 meta: &OutcomeMeta,
316) -> Result<Vec<HyperliquidInstrumentDef>, String> {
317 let mut defs = Vec::with_capacity(meta.outcomes.len() * 2);
318
319 for market in &meta.outcomes {
320 for side in 0u8..=1u8 {
321 defs.push(build_outcome_def(market, side, meta)?);
322 }
323 }
324
325 Ok(defs)
326}
327
328fn build_outcome_def(
329 market: &OutcomeMarket,
330 side: u8,
331 meta: &OutcomeMeta,
332) -> Result<HyperliquidInstrumentDef, String> {
333 let outcome = market.outcome;
334 let asset_id = HyperliquidAssetId::outcome(outcome, side);
335 let encoding = asset_id
336 .outcome_encoding()
337 .ok_or_else(|| format!("Invalid outcome encoding for outcome={outcome} side={side}"))?;
338
339 let token = format!("+{encoding}");
340 let coin = format!("#{encoding}");
341
342 let side_name = market
343 .side_specs
344 .get(usize::from(side))
345 .map(|spec| Ustr::from(spec.name.as_str()));
346
347 let description = if market.description.is_empty() {
348 None
349 } else {
350 Some(Ustr::from(market.description.as_str()))
351 };
352
353 let expiration_ns = resolve_outcome_expiration_ns(market, meta);
354
355 let outcome = HyperliquidOutcomeMetadata {
356 outcome_index: market.outcome,
357 outcome_side: side,
358 market_name: Ustr::from(market.name.as_str()),
359 side_name,
360 description,
361 activation_ns: UnixNanos::default(),
362 expiration_ns,
363 };
364
365 Ok(HyperliquidInstrumentDef {
366 symbol: Ustr::from(token.as_str()),
367 raw_symbol: Ustr::from(coin.as_str()),
368 base: Ustr::from(token.as_str()),
369 quote: "USDH".into(),
370 market_type: HyperliquidMarketType::Outcome,
371 asset_index: asset_id.to_raw(),
372 price_decimals: OUTCOME_PRICE_DECIMALS,
373 size_decimals: OUTCOME_SIZE_DECIMALS,
374 tick_size: pow10_neg(OUTCOME_PRICE_DECIMALS),
375 lot_size: pow10_neg(OUTCOME_SIZE_DECIMALS),
376 max_leverage: None,
377 only_isolated: false,
378 is_hip3: false,
379 active: true,
380 outcome: Some(outcome),
381 raw_data: serde_json::to_string(market).unwrap_or_default(),
382 })
383}
384
385fn pow10_neg(decimals: u32) -> Decimal {
386 if decimals == 0 {
387 return Decimal::ONE;
388 }
389
390 Decimal::from_i128_with_scale(1, decimals)
392}
393
394fn resolve_outcome_expiration_ns(market: &OutcomeMarket, meta: &OutcomeMeta) -> UnixNanos {
398 if let Some(ns) = parse_expiry_from_description(&market.description) {
399 return ns;
400 }
401
402 meta.parent_question(market.outcome)
403 .and_then(|q| parse_expiry_from_description(&q.description))
404 .unwrap_or_default()
405}
406
407fn parse_expiry_from_description(description: &str) -> Option<UnixNanos> {
408 description
409 .split('|')
410 .filter_map(|piece| piece.split_once(':'))
411 .find_map(|(key, value)| (key == "expiry").then_some(value))
412 .and_then(parse_outcome_expiry_ns)
413}
414
415fn parse_outcome_expiry_ns(s: &str) -> Option<UnixNanos> {
417 let (date_part, time_part) = s.split_once('-')?;
418 if date_part.len() != 8 || time_part.len() != 4 {
419 return None;
420 }
421
422 let year: i32 = date_part[0..4].parse().ok()?;
423 let month: u32 = date_part[4..6].parse().ok()?;
424 let day: u32 = date_part[6..8].parse().ok()?;
425 let hour: u32 = time_part[0..2].parse().ok()?;
426 let minute: u32 = time_part[2..4].parse().ok()?;
427
428 let datetime = chrono::NaiveDate::from_ymd_opt(year, month, day)?
429 .and_hms_opt(hour, minute, 0)?
430 .and_utc();
431 let nanos = datetime.timestamp_nanos_opt()?;
432 u64::try_from(nanos).ok().map(UnixNanos::from)
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub struct OutcomeSettlement {
438 pub outcome_index: u32,
440 pub outcome_side: u8,
442 pub final_value: u8,
444}
445
446#[must_use]
463pub fn derive_outcome_settlements(meta: &OutcomeMeta) -> Vec<OutcomeSettlement> {
464 let mut settlements = Vec::new();
465
466 for question in &meta.questions {
467 if question.settled_named_outcomes.is_empty() {
468 continue;
469 }
470
471 let losing_sides_won = |outcome_index: u32| -> [OutcomeSettlement; 2] {
472 [
474 OutcomeSettlement {
475 outcome_index,
476 outcome_side: 0,
477 final_value: 0,
478 },
479 OutcomeSettlement {
480 outcome_index,
481 outcome_side: 1,
482 final_value: 1,
483 },
484 ]
485 };
486
487 let winning_sides = |outcome_index: u32| -> [OutcomeSettlement; 2] {
488 [
490 OutcomeSettlement {
491 outcome_index,
492 outcome_side: 0,
493 final_value: 1,
494 },
495 OutcomeSettlement {
496 outcome_index,
497 outcome_side: 1,
498 final_value: 0,
499 },
500 ]
501 };
502
503 for outcome_index in &question.named_outcomes {
504 if question.settled_named_outcomes.contains(outcome_index) {
505 settlements.extend(winning_sides(*outcome_index));
506 } else {
507 settlements.extend(losing_sides_won(*outcome_index));
508 }
509 }
510
511 if let Some(fallback) = question.fallback_outcome {
514 settlements.extend(losing_sides_won(fallback));
515 }
516 }
517
518 settlements
519}
520
521pub fn get_currency(code: &str) -> Currency {
522 Currency::try_from_str(code).unwrap_or_else(|| {
523 let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
524 if let Err(e) = Currency::register(currency, false) {
525 log::error!("Failed to register currency '{code}': {e}");
526 }
527 currency
528 })
529}
530
531pub fn get_usdh_currency() -> Currency {
538 Currency::try_from_str("USDH").unwrap_or_else(|| {
539 let currency = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
540 if let Err(e) = Currency::register(currency, false) {
541 log::error!("Failed to register USDH currency: {e}");
542 }
543 currency
544 })
545}
546
547pub fn resolve_fee_currency(
563 fee_token: &str,
564 fee_amount: Decimal,
565 instrument: &dyn Instrument,
566) -> anyhow::Result<Currency> {
567 if is_outcome_side_token(fee_token) {
568 if !fee_amount.is_zero() {
569 anyhow::bail!(
570 "Outcome side token '{fee_token}' carried a non-zero fee {fee_amount}; \
571 venue does not denominate fees in side tokens",
572 );
573 }
574 return Ok(instrument.quote_currency());
575 }
576
577 if let Some(currency) = Currency::try_from_str(fee_token) {
578 return Ok(currency);
579 }
580
581 if fee_amount.is_zero() {
582 let fallback = instrument.quote_currency();
583 log::debug!(
584 "Unregistered fee token '{fee_token}' on zero-fee fill for {}; using {fallback} as fallback",
585 instrument.id(),
586 );
587 return Ok(fallback);
588 }
589
590 anyhow::bail!("Unknown fee token '{fee_token}' with non-zero fee {fee_amount}")
591}
592
593fn is_outcome_side_token(symbol: &str) -> bool {
594 let Some(rest) = symbol.strip_prefix('+') else {
595 return false;
596 };
597 !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
598}
599
600#[must_use]
604pub fn create_instrument_from_def(
605 def: &HyperliquidInstrumentDef,
606 ts_init: UnixNanos,
607) -> Option<InstrumentAny> {
608 let symbol = Symbol::new(def.symbol);
609 let venue = *HYPERLIQUID_VENUE;
610 let instrument_id = InstrumentId::new(symbol, venue);
611
612 let raw_symbol = Symbol::new(def.raw_symbol);
617 let price_increment = Price::from(def.tick_size.to_string());
618 let size_increment = Quantity::from(def.lot_size.to_string());
619
620 match def.market_type {
621 HyperliquidMarketType::Spot => {
622 let base_currency = get_currency(&def.base);
623 let quote_currency = get_currency(&def.quote);
624
625 Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
626 instrument_id,
627 raw_symbol,
628 base_currency,
629 quote_currency,
630 def.price_decimals as u8,
631 def.size_decimals as u8,
632 price_increment,
633 size_increment,
634 None,
635 None,
636 None,
637 None,
638 None,
639 None,
640 None,
641 None,
642 None,
643 None,
644 None,
645 None,
646 None,
647 ts_init, ts_init,
649 )))
650 }
651 HyperliquidMarketType::Perp => {
652 let base_currency = get_currency(&def.base);
653 let quote_currency = get_currency(&def.quote);
654 let settlement_currency = get_currency("USDC");
655
656 Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
657 instrument_id,
658 raw_symbol,
659 base_currency,
660 quote_currency,
661 settlement_currency,
662 false,
663 def.price_decimals as u8,
664 def.size_decimals as u8,
665 price_increment,
666 size_increment,
667 None, None,
669 None,
670 None,
671 None,
672 None,
673 None,
674 None,
675 None,
676 None,
677 None,
678 None,
679 None,
680 ts_init, ts_init,
682 )))
683 }
684 HyperliquidMarketType::Outcome => {
685 let outcome = def.outcome.as_ref()?;
686 let currency = get_usdh_currency();
687
688 Some(InstrumentAny::BinaryOption(BinaryOption::new(
689 instrument_id,
690 raw_symbol,
691 AssetClass::Alternative,
692 currency,
693 outcome.activation_ns,
694 outcome.expiration_ns,
695 def.price_decimals as u8,
696 def.size_decimals as u8,
697 price_increment,
698 size_increment,
699 outcome.side_name,
700 outcome.description,
701 None, None, None, None, None, None, None, None, None, None, None, ts_init,
713 ts_init,
714 )))
715 }
716 }
717}
718
719#[must_use]
722pub fn instruments_from_defs(
723 defs: &[HyperliquidInstrumentDef],
724 ts_init: UnixNanos,
725) -> Vec<InstrumentAny> {
726 defs.iter()
727 .filter_map(|def| create_instrument_from_def(def, ts_init))
728 .collect()
729}
730
731#[must_use]
733pub fn instruments_from_defs_owned(
734 defs: Vec<HyperliquidInstrumentDef>,
735 ts_init: UnixNanos,
736) -> Vec<InstrumentAny> {
737 defs.into_iter()
738 .filter_map(|def| create_instrument_from_def(&def, ts_init))
739 .collect()
740}
741
742fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
743 match side {
744 HyperliquidSide::Buy => OrderSide::Buy,
745 HyperliquidSide::Sell => OrderSide::Sell,
746 }
747}
748
749pub fn parse_order_status_report_from_ws(
755 order_data: &WsOrderData,
756 instrument: &dyn Instrument,
757 account_id: AccountId,
758 ts_init: UnixNanos,
759) -> anyhow::Result<OrderStatusReport> {
760 parse_order_status_report_from_basic(
761 &order_data.order,
762 &order_data.status,
763 instrument,
764 account_id,
765 ts_init,
766 )
767}
768
769pub fn parse_order_status_report_from_basic(
775 order: &WsBasicOrderData,
776 status: &HyperliquidOrderStatusEnum,
777 instrument: &dyn Instrument,
778 account_id: AccountId,
779 ts_init: UnixNanos,
780) -> anyhow::Result<OrderStatusReport> {
781 let instrument_id = instrument.id();
782 let venue_order_id = VenueOrderId::new(order.oid.to_string());
783 let order_side = OrderSide::from(order.side);
784
785 let order_type = if order.trigger_px.is_some() {
787 if order.is_market == Some(true) {
788 match order.tpsl.as_ref() {
790 Some(HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
791 Some(HyperliquidTpSl::Sl) => OrderType::StopMarket,
792 _ => OrderType::StopMarket,
793 }
794 } else {
795 match order.tpsl.as_ref() {
796 Some(HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
797 Some(HyperliquidTpSl::Sl) => OrderType::StopLimit,
798 _ => OrderType::StopLimit,
799 }
800 }
801 } else {
802 OrderType::Limit
803 };
804
805 let time_in_force = TimeInForce::Gtc;
806 let order_status = OrderStatus::from(*status);
807
808 let price_precision = instrument.price_precision();
809 let size_precision = instrument.size_precision();
810
811 let orig_sz: Decimal = order
812 .orig_sz
813 .parse()
814 .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
815 let current_sz: Decimal = order
816 .sz
817 .parse()
818 .map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
819
820 let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
821 .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
822 let filled_sz = orig_sz.abs() - current_sz.abs();
823 let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
824 .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
825
826 let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
827 let ts_last = ts_accepted;
828 let report_id = UUID4::new();
829
830 let mut report = OrderStatusReport::new(
831 account_id,
832 instrument_id,
833 None, venue_order_id,
835 order_side,
836 order_type,
837 time_in_force,
838 order_status,
839 quantity,
840 filled_qty,
841 ts_accepted,
842 ts_last,
843 ts_init,
844 Some(report_id),
845 );
846
847 if let Some(cloid) = &order.cloid {
849 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
850 }
851
852 if !matches!(
856 order_status,
857 OrderStatus::Filled | OrderStatus::PartiallyFilled
858 ) {
859 let limit_px: Decimal = order
860 .limit_px
861 .parse()
862 .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
863 let price = Price::from_decimal_dp(limit_px, price_precision)
864 .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
865 report = report.with_price(price);
866 }
867
868 if let Some(trigger_px) = &order.trigger_px {
870 let trig_px: Decimal = trigger_px
871 .parse()
872 .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
873 let trigger_price = Price::from_decimal_dp(trig_px, price_precision)
874 .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
875 report = report
876 .with_trigger_price(trigger_price)
877 .with_trigger_type(TriggerType::Default);
878 }
879
880 Ok(report)
881}
882
883pub fn parse_fill_report(
889 fill: &HyperliquidFill,
890 instrument: &dyn Instrument,
891 account_id: AccountId,
892 ts_init: UnixNanos,
893) -> anyhow::Result<FillReport> {
894 let instrument_id = instrument.id();
895 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
896
897 if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
898 log::warn!(
899 "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
900 fill.oid,
901 fill.px,
902 fill.sz,
903 );
904 }
905
906 let trade_id = make_fill_trade_id(
907 &fill.hash,
908 fill.oid,
909 &fill.px,
910 &fill.sz,
911 fill.time,
912 &fill.start_position,
913 );
914 let order_side = parse_fill_side(&fill.side);
915
916 let price_precision = instrument.price_precision();
917 let size_precision = instrument.size_precision();
918
919 let px: Decimal = fill
920 .px
921 .parse()
922 .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
923 let sz: Decimal = fill
924 .sz
925 .parse()
926 .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
927
928 let last_px = Price::from_decimal_dp(px, price_precision)
929 .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
930 let last_qty = Quantity::from_decimal_dp(sz.abs(), size_precision)
931 .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
932
933 let fee_amount: Decimal = fill
934 .fee
935 .parse()
936 .map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
937
938 let fee_currency = resolve_fee_currency(fill.fee_token.as_str(), fee_amount, instrument)?;
939 let commission = Money::from_decimal(fee_amount, fee_currency)
940 .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
941
942 let liquidity_side = if fill.crossed {
944 LiquiditySide::Taker
945 } else {
946 LiquiditySide::Maker
947 };
948
949 let ts_event = UnixNanos::from(fill.time * 1_000_000);
950 let report_id = UUID4::new();
951
952 let report = FillReport::new(
953 account_id,
954 instrument_id,
955 venue_order_id,
956 trade_id,
957 order_side,
958 last_qty,
959 last_px,
960 commission,
961 liquidity_side,
962 None, None, ts_event,
965 ts_init,
966 Some(report_id),
967 );
968
969 Ok(report)
970}
971
972pub fn parse_position_status_report(
978 position_data: &serde_json::Value,
979 instrument: &dyn Instrument,
980 account_id: AccountId,
981 ts_init: UnixNanos,
982) -> anyhow::Result<PositionStatusReport> {
983 let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
985 .context("failed to deserialize AssetPosition")?;
986
987 let position = &asset_position.position;
988 let instrument_id = instrument.id();
989
990 let (position_side, quantity_value) = if position.szi.is_zero() {
992 (PositionSideSpecified::Flat, Decimal::ZERO)
993 } else if position.szi.is_sign_positive() {
994 (PositionSideSpecified::Long, position.szi)
995 } else {
996 (PositionSideSpecified::Short, position.szi.abs())
997 };
998
999 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1000 .context("failed to create quantity from decimal")?;
1001 let report_id = UUID4::new();
1002 let ts_last = ts_init;
1003 let avg_px_open = position.entry_px;
1004
1005 Ok(PositionStatusReport::new(
1007 account_id,
1008 instrument_id,
1009 position_side,
1010 quantity,
1011 ts_last,
1012 ts_init,
1013 Some(report_id),
1014 None, avg_px_open,
1016 ))
1017}
1018
1019pub fn parse_spot_position_status_report(
1029 balance: &SpotBalance,
1030 instrument: &dyn Instrument,
1031 account_id: AccountId,
1032 ts_init: UnixNanos,
1033) -> anyhow::Result<PositionStatusReport> {
1034 let (position_side, quantity_value) = if balance.total.is_zero() {
1035 (PositionSideSpecified::Flat, Decimal::ZERO)
1036 } else {
1037 (PositionSideSpecified::Long, balance.total)
1038 };
1039
1040 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1041 .context("failed to create spot quantity from decimal")?;
1042
1043 Ok(PositionStatusReport::new(
1044 account_id,
1045 instrument.id(),
1046 position_side,
1047 quantity,
1048 ts_init,
1049 ts_init,
1050 Some(UUID4::new()),
1051 None,
1052 balance.avg_entry_px(),
1053 ))
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use rstest::rstest;
1059 use rust_decimal_macros::dec;
1060
1061 use super::{
1062 super::models::{
1063 HyperliquidL2Book, OutcomeMarket, OutcomeMeta, OutcomeQuestion, OutcomeSideSpec,
1064 PerpAsset, SpotPair, SpotToken,
1065 },
1066 *,
1067 };
1068
1069 #[rstest]
1070 fn test_parse_fill_side() {
1071 assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
1072 assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
1073 }
1074
1075 #[rstest]
1076 fn test_pow10_neg() {
1077 assert_eq!(pow10_neg(0), dec!(1));
1078 assert_eq!(pow10_neg(1), dec!(0.1));
1079 assert_eq!(pow10_neg(5), dec!(0.00001));
1080 }
1081
1082 #[rstest]
1083 fn test_parse_perp_instruments() {
1084 let meta = PerpMeta {
1085 universe: vec![
1086 PerpAsset {
1087 name: "BTC".to_string(),
1088 sz_decimals: 5,
1089 max_leverage: Some(50),
1090 ..Default::default()
1091 },
1092 PerpAsset {
1093 name: "DELIST".to_string(),
1094 sz_decimals: 3,
1095 max_leverage: Some(10),
1096 only_isolated: Some(true),
1097 is_delisted: Some(true),
1098 ..Default::default()
1099 },
1100 ],
1101 margin_tables: vec![],
1102 };
1103
1104 let defs = parse_perp_instruments(&meta, 0).unwrap();
1105
1106 assert_eq!(defs.len(), 2);
1108
1109 let btc = &defs[0];
1110 assert_eq!(btc.symbol, "BTC-USD-PERP");
1111 assert_eq!(btc.base, "BTC");
1112 assert_eq!(btc.quote, "USD");
1113 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1114 assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
1116 assert_eq!(btc.tick_size, dec!(0.1));
1117 assert_eq!(btc.lot_size, dec!(0.00001));
1118 assert_eq!(btc.max_leverage, Some(50));
1119 assert!(!btc.only_isolated);
1120 assert!(btc.active);
1121
1122 let delist = &defs[1];
1123 assert_eq!(delist.symbol, "DELIST-USD-PERP");
1124 assert_eq!(delist.base, "DELIST");
1125 assert!(!delist.active); }
1127
1128 use crate::common::testing::load_test_data;
1129
1130 #[rstest]
1131 fn test_parse_perp_instruments_from_real_data() {
1132 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1133
1134 let defs = parse_perp_instruments(&meta, 0).unwrap();
1135
1136 assert_eq!(defs.len(), 3);
1138
1139 let btc = &defs[0];
1141 assert_eq!(btc.symbol, "BTC-USD-PERP");
1142 assert_eq!(btc.base, "BTC");
1143 assert_eq!(btc.quote, "USD");
1144 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1145 assert_eq!(btc.size_decimals, 5);
1146 assert_eq!(btc.max_leverage, Some(40));
1147 assert!(btc.active);
1148
1149 let eth = &defs[1];
1151 assert_eq!(eth.symbol, "ETH-USD-PERP");
1152 assert_eq!(eth.base, "ETH");
1153 assert_eq!(eth.size_decimals, 4);
1154 assert_eq!(eth.max_leverage, Some(25));
1155
1156 let atom = &defs[2];
1158 assert_eq!(atom.symbol, "ATOM-USD-PERP");
1159 assert_eq!(atom.base, "ATOM");
1160 assert_eq!(atom.size_decimals, 2);
1161 assert_eq!(atom.max_leverage, Some(5));
1162 }
1163
1164 #[rstest]
1165 fn test_deserialize_l2_book_from_real_data() {
1166 let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
1167
1168 assert_eq!(book.coin, "BTC");
1170 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];
1176 let asks = &book.levels[1];
1177
1178 for i in 1..bids.len() {
1180 let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
1181 let curr_price = bids[i].px.parse::<f64>().unwrap();
1182 assert!(prev_price >= curr_price, "Bids should be descending");
1183 }
1184
1185 for i in 1..asks.len() {
1187 let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
1188 let curr_price = asks[i].px.parse::<f64>().unwrap();
1189 assert!(prev_price <= curr_price, "Asks should be ascending");
1190 }
1191 }
1192
1193 #[rstest]
1194 fn test_parse_spot_instruments() {
1195 let tokens = vec![
1196 SpotToken {
1197 name: "USDC".to_string(),
1198 sz_decimals: 6,
1199 wei_decimals: 6,
1200 index: 0,
1201 token_id: "0x1".to_string(),
1202 is_canonical: true,
1203 evm_contract: None,
1204 full_name: None,
1205 deployer_trading_fee_share: None,
1206 },
1207 SpotToken {
1208 name: "PURR".to_string(),
1209 sz_decimals: 0,
1210 wei_decimals: 5,
1211 index: 1,
1212 token_id: "0x2".to_string(),
1213 is_canonical: true,
1214 evm_contract: None,
1215 full_name: None,
1216 deployer_trading_fee_share: None,
1217 },
1218 ];
1219
1220 let pairs = vec![
1221 SpotPair {
1222 name: "PURR/USDC".to_string(),
1223 tokens: [1, 0], index: 0,
1225 is_canonical: true,
1226 },
1227 SpotPair {
1228 name: "ALIAS".to_string(),
1229 tokens: [1, 0],
1230 index: 1,
1231 is_canonical: false, },
1233 ];
1234
1235 let meta = SpotMeta {
1236 tokens,
1237 universe: pairs,
1238 };
1239
1240 let defs = parse_spot_instruments(&meta).unwrap();
1241
1242 assert_eq!(defs.len(), 2);
1244
1245 let purr_usdc = &defs[0];
1246 assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
1247 assert_eq!(purr_usdc.base, "PURR");
1248 assert_eq!(purr_usdc.quote, "USDC");
1249 assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
1250 assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
1252 assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
1253 assert_eq!(purr_usdc.lot_size, dec!(1));
1254 assert_eq!(purr_usdc.max_leverage, None);
1255 assert!(!purr_usdc.only_isolated);
1256 assert!(purr_usdc.active);
1257
1258 let alias = &defs[1];
1259 assert_eq!(alias.symbol, "PURR-USDC-SPOT");
1260 assert_eq!(alias.base, "PURR");
1261 assert!(!alias.active); }
1263
1264 #[rstest]
1265 fn test_parse_spot_instruments_sorts_canonical_before_non_canonical() {
1266 let tokens = vec![
1270 SpotToken {
1271 name: "USDC".to_string(),
1272 sz_decimals: 6,
1273 wei_decimals: 6,
1274 index: 0,
1275 token_id: "0x1".to_string(),
1276 is_canonical: true,
1277 evm_contract: None,
1278 full_name: None,
1279 deployer_trading_fee_share: None,
1280 },
1281 SpotToken {
1282 name: "HYPE".to_string(),
1283 sz_decimals: 2,
1284 wei_decimals: 8,
1285 index: 150,
1286 token_id: "0x2".to_string(),
1287 is_canonical: true,
1288 evm_contract: None,
1289 full_name: None,
1290 deployer_trading_fee_share: None,
1291 },
1292 ];
1293
1294 let pairs = vec![
1295 SpotPair {
1296 name: "HYPE_OLD".to_string(),
1297 tokens: [150, 0],
1298 index: 3,
1299 is_canonical: false,
1300 },
1301 SpotPair {
1302 name: "HYPE".to_string(),
1303 tokens: [150, 0],
1304 index: 107,
1305 is_canonical: true,
1306 },
1307 ];
1308
1309 let defs = parse_spot_instruments(&SpotMeta {
1310 tokens,
1311 universe: pairs,
1312 })
1313 .unwrap();
1314
1315 assert_eq!(defs.len(), 2);
1316 assert!(defs[0].active, "canonical must sort first");
1317 assert_eq!(defs[0].asset_index, 10000 + 107);
1318 assert!(!defs[1].active);
1319 assert_eq!(defs[1].asset_index, 10000 + 3);
1320 }
1321
1322 #[rstest]
1323 fn test_price_decimals_clamping() {
1324 let meta = PerpMeta {
1325 universe: vec![PerpAsset {
1326 name: "HIGHPREC".to_string(),
1327 sz_decimals: 10, max_leverage: Some(1),
1329 ..Default::default()
1330 }],
1331 margin_tables: vec![],
1332 };
1333
1334 let defs = parse_perp_instruments(&meta, 0).unwrap();
1335 assert_eq!(defs[0].price_decimals, 0);
1336 assert_eq!(defs[0].tick_size, dec!(1));
1337 }
1338
1339 #[rstest]
1340 fn test_parse_perp_instruments_hip3_dex() {
1341 let meta = PerpMeta {
1343 universe: vec![
1344 PerpAsset {
1345 name: "xyz:TSLA".to_string(),
1346 sz_decimals: 3,
1347 max_leverage: Some(10),
1348 only_isolated: None,
1349 is_delisted: None,
1350 growth_mode: Some("enabled".to_string()),
1351 margin_mode: Some("strictIsolated".to_string()),
1352 },
1353 PerpAsset {
1354 name: "xyz:NVDA".to_string(),
1355 sz_decimals: 3,
1356 max_leverage: Some(20),
1357 only_isolated: None,
1358 is_delisted: None,
1359 growth_mode: None,
1360 margin_mode: None,
1361 },
1362 ],
1363 margin_tables: vec![],
1364 };
1365
1366 let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1367 assert_eq!(defs.len(), 2);
1368
1369 assert_eq!(defs[0].symbol, "xyz:TSLA-USD-PERP");
1371 assert!(defs[0].symbol.contains(':'));
1372 assert_eq!(defs[0].base, "xyz:TSLA");
1373 assert_eq!(defs[0].asset_index, 110_000);
1374 assert!(defs[0].active);
1375
1376 assert_eq!(defs[1].symbol, "xyz:NVDA-USD-PERP");
1377 assert_eq!(defs[1].asset_index, 110_001);
1378 }
1379
1380 #[rstest]
1381 #[case("BTC", "BTC")]
1382 #[case("kPEPE", "kPEPE")]
1383 #[case("xyz:TSLA", "xyz:TSLA")]
1384 #[case("dex:STREAMABCD****", "dex:STREAMABCDxxxx")]
1385 #[case("ABC?", "ABCx")]
1386 #[case("a*b?c", "axbxc")]
1387 fn test_sanitize_symbol(#[case] input: &str, #[case] expected: &str) {
1388 assert_eq!(sanitize_symbol(input), expected);
1389 }
1390
1391 #[rstest]
1392 fn test_parse_spot_instruments_sanitizes_wildcard_token_names() {
1393 let tokens = vec![
1397 SpotToken {
1398 name: "USDC".to_string(),
1399 sz_decimals: 6,
1400 wei_decimals: 6,
1401 index: 0,
1402 token_id: "0x1".to_string(),
1403 is_canonical: true,
1404 evm_contract: None,
1405 full_name: None,
1406 deployer_trading_fee_share: None,
1407 },
1408 SpotToken {
1409 name: "ABC?".to_string(),
1410 sz_decimals: 4,
1411 wei_decimals: 4,
1412 index: 1,
1413 token_id: "0x2".to_string(),
1414 is_canonical: true,
1415 evm_contract: None,
1416 full_name: None,
1417 deployer_trading_fee_share: None,
1418 },
1419 ];
1420
1421 let pairs = vec![SpotPair {
1422 name: "ABC?/USDC".to_string(),
1423 tokens: [1, 0],
1424 index: 50,
1425 is_canonical: true,
1426 }];
1427
1428 let meta = SpotMeta {
1429 tokens,
1430 universe: pairs,
1431 };
1432
1433 let defs = parse_spot_instruments(&meta).unwrap();
1434 assert_eq!(defs.len(), 1);
1435 assert_eq!(defs[0].symbol, "ABCx-USDC-SPOT");
1436 assert_eq!(defs[0].base, "ABC?");
1437 assert_eq!(defs[0].quote, "USDC");
1438 }
1439
1440 #[rstest]
1441 fn test_parse_perp_instruments_sanitizes_hip3_wildcards() {
1442 let meta = PerpMeta {
1443 universe: vec![PerpAsset {
1444 name: "dex:STREAMABCD****".to_string(),
1445 sz_decimals: 3,
1446 max_leverage: Some(10),
1447 only_isolated: None,
1448 is_delisted: None,
1449 growth_mode: None,
1450 margin_mode: None,
1451 }],
1452 margin_tables: vec![],
1453 };
1454
1455 let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1456 assert_eq!(defs.len(), 1);
1457 assert_eq!(defs[0].symbol, "dex:STREAMABCDxxxx-USD-PERP");
1458 assert_eq!(defs[0].raw_symbol.as_str(), "dex:STREAMABCD****");
1459 assert_eq!(defs[0].base.as_str(), "dex:STREAMABCD****");
1460 }
1461
1462 #[rstest]
1463 fn test_parse_outcome_instruments_emits_both_sides() {
1464 let meta = OutcomeMeta {
1465 outcomes: vec![OutcomeMarket {
1466 outcome: 1,
1467 name: "BTC daily".to_string(),
1468 description: "BTC settles above strike at 06:00 UTC".to_string(),
1469 side_specs: vec![
1470 OutcomeSideSpec {
1471 name: "Yes".to_string(),
1472 },
1473 OutcomeSideSpec {
1474 name: "No".to_string(),
1475 },
1476 ],
1477 }],
1478 questions: vec![],
1479 };
1480
1481 let defs = parse_outcome_instruments(&meta).unwrap();
1482 assert_eq!(defs.len(), 2);
1483
1484 let yes = &defs[0];
1485 assert_eq!(yes.symbol.as_str(), "+10");
1486 assert_eq!(yes.raw_symbol.as_str(), "#10");
1487 assert_eq!(yes.market_type, HyperliquidMarketType::Outcome);
1488 assert_eq!(yes.asset_index, 100_000_010);
1489 assert_eq!(yes.price_decimals, OUTCOME_PRICE_DECIMALS);
1490 assert_eq!(yes.size_decimals, OUTCOME_SIZE_DECIMALS);
1491 assert_eq!(yes.tick_size, dec!(0.0001));
1492 assert_eq!(yes.lot_size, dec!(0.01));
1493 assert_eq!(yes.quote.as_str(), "USDH");
1494 assert!(yes.active);
1495
1496 let yes_meta = yes.outcome.as_ref().unwrap();
1497 assert_eq!(yes_meta.outcome_index, 1);
1498 assert_eq!(yes_meta.outcome_side, 0);
1499 assert_eq!(yes_meta.market_name.as_str(), "BTC daily");
1500 assert_eq!(yes_meta.side_name.unwrap().as_str(), "Yes");
1501 assert_eq!(
1502 yes_meta.description.unwrap().as_str(),
1503 "BTC settles above strike at 06:00 UTC"
1504 );
1505
1506 let no = &defs[1];
1507 assert_eq!(no.symbol.as_str(), "+11");
1508 assert_eq!(no.raw_symbol.as_str(), "#11");
1509 assert_eq!(no.asset_index, 100_000_011);
1510 let no_meta = no.outcome.as_ref().unwrap();
1511 assert_eq!(no_meta.outcome_side, 1);
1512 assert_eq!(no_meta.side_name.unwrap().as_str(), "No");
1513 }
1514
1515 #[rstest]
1516 fn test_parse_outcome_instruments_handles_missing_side_specs() {
1517 let meta = OutcomeMeta {
1518 outcomes: vec![OutcomeMarket {
1519 outcome: 5,
1520 name: "Recurring".to_string(),
1521 description: String::new(),
1522 side_specs: vec![],
1523 }],
1524 questions: vec![],
1525 };
1526
1527 let defs = parse_outcome_instruments(&meta).unwrap();
1528 assert_eq!(defs.len(), 2);
1529
1530 for def in &defs {
1531 let outcome = def.outcome.as_ref().unwrap();
1532 assert!(outcome.side_name.is_none());
1533 assert!(outcome.description.is_none());
1534 }
1535
1536 assert_eq!(defs[0].asset_index, 100_000_050);
1537 assert_eq!(defs[1].asset_index, 100_000_051);
1538 }
1539
1540 #[rstest]
1541 fn test_get_usdh_currency_registers_with_explicit_precision() {
1542 let currency = get_usdh_currency();
1543 assert_eq!(currency.code.as_str(), "USDH");
1544 assert_eq!(currency.precision, 8);
1545 assert_eq!(currency.currency_type, CurrencyType::Crypto);
1546
1547 let again = get_usdh_currency();
1549 assert_eq!(again, currency);
1550 assert!(Currency::try_from_str("USDH").is_some());
1551 }
1552
1553 #[rstest]
1554 fn test_create_instrument_from_def_outcome_emits_binary_option() {
1555 let meta = OutcomeMeta {
1556 outcomes: vec![OutcomeMarket {
1557 outcome: 2,
1558 name: "Recurring BTC".to_string(),
1559 description: "Daily settlement".to_string(),
1560 side_specs: vec![
1561 OutcomeSideSpec {
1562 name: "Yes".to_string(),
1563 },
1564 OutcomeSideSpec {
1565 name: "No".to_string(),
1566 },
1567 ],
1568 }],
1569 questions: vec![],
1570 };
1571
1572 let defs = parse_outcome_instruments(&meta).unwrap();
1573 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1574
1575 match instrument {
1576 InstrumentAny::BinaryOption(bo) => {
1577 assert_eq!(bo.id.symbol.as_str(), "+20");
1578 assert_eq!(bo.raw_symbol.as_str(), "#20");
1579 assert_eq!(bo.asset_class, AssetClass::Alternative);
1580 assert_eq!(bo.currency.code.as_str(), "USDH");
1581 assert_eq!(bo.price_precision, OUTCOME_PRICE_DECIMALS as u8);
1582 assert_eq!(bo.size_precision, OUTCOME_SIZE_DECIMALS as u8);
1583 assert_eq!(bo.outcome.unwrap().as_str(), "Yes");
1584 assert_eq!(bo.description.unwrap().as_str(), "Daily settlement");
1585 }
1586 other => panic!("Expected BinaryOption, was {other:?}"),
1587 }
1588 }
1589
1590 #[rstest]
1591 fn test_parse_fill_report_outcome_round_trip() {
1592 let meta = OutcomeMeta {
1593 outcomes: vec![OutcomeMarket {
1594 outcome: 42,
1595 name: "BTC daily".to_string(),
1596 description: "BTC settles above strike at 06:00 UTC".to_string(),
1597 side_specs: vec![
1598 OutcomeSideSpec {
1599 name: "Yes".to_string(),
1600 },
1601 OutcomeSideSpec {
1602 name: "No".to_string(),
1603 },
1604 ],
1605 }],
1606 questions: vec![],
1607 };
1608
1609 let defs = parse_outcome_instruments(&meta).unwrap();
1610 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1611 assert_eq!(yes.id().symbol.as_str(), "+420");
1612
1613 let fill = HyperliquidFill {
1614 coin: Ustr::from("#420"),
1615 px: "0.5500".to_string(),
1616 sz: "1000.00".to_string(),
1617 side: HyperliquidSide::Buy,
1618 time: 1_704_470_400_000,
1619 start_position: "0.00".to_string(),
1620 dir: HyperliquidFillDirection::OpenLong,
1621 closed_pnl: "0.0".to_string(),
1622 hash: "0xfeed".to_string(),
1623 oid: 99_001,
1624 crossed: true,
1625 fee: "0.0".to_string(),
1626 fee_token: Ustr::from("+420"),
1627 };
1628
1629 let account_id = AccountId::from("HYPERLIQUID-001");
1630 let report = parse_fill_report(&fill, &yes, account_id, UnixNanos::default()).unwrap();
1631
1632 assert_eq!(report.commission.currency.code.as_str(), "USDH");
1636 assert!(report.commission.as_decimal().is_zero());
1637 assert_eq!(report.order_side, OrderSide::Buy);
1638 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1639 assert_eq!(report.last_qty.as_decimal(), dec!(1000));
1640 assert_eq!(report.last_px.as_decimal(), dec!(0.55));
1641 }
1642
1643 #[rstest]
1644 fn test_resolve_fee_currency_outcome_token_returns_quote_even_when_registered() {
1645 let meta = OutcomeMeta {
1646 outcomes: vec![OutcomeMarket {
1647 outcome: 88,
1648 name: "Edge".to_string(),
1649 description: String::new(),
1650 side_specs: vec![],
1651 }],
1652 questions: vec![],
1653 };
1654 let defs = parse_outcome_instruments(&meta).unwrap();
1655 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1656
1657 let _ = get_currency("+880");
1660 assert!(Currency::try_from_str("+880").is_some());
1661
1662 let currency = resolve_fee_currency("+880", Decimal::ZERO, &yes)
1663 .expect("zero-fee outcome side token must resolve to quote currency");
1664 assert_eq!(currency.code.as_str(), "USDH");
1665
1666 let err = resolve_fee_currency("+880", dec!(0.01), &yes).unwrap_err();
1667 let err_msg = err.to_string();
1668 assert!(err_msg.contains("Outcome side token '+880'"));
1669 assert!(err_msg.contains("non-zero fee"));
1670 }
1671
1672 #[rstest]
1673 #[case("+50", true)]
1674 #[case("+0", true)]
1675 #[case("+880", true)]
1676 #[case("", false)]
1677 #[case("+", false)]
1678 #[case("+abc", false)]
1679 #[case("+50a", false)]
1680 #[case("#50", false)]
1681 #[case("USDC", false)]
1682 #[case("-50", false)]
1683 fn test_is_outcome_side_token(#[case] input: &str, #[case] expected: bool) {
1684 assert_eq!(is_outcome_side_token(input), expected);
1685 }
1686
1687 #[rstest]
1688 fn test_resolve_fee_currency_falls_back_to_quote_when_unregistered_and_zero_fee() {
1689 let meta = OutcomeMeta {
1690 outcomes: vec![OutcomeMarket {
1691 outcome: 77,
1692 name: "Edge".to_string(),
1693 description: String::new(),
1694 side_specs: vec![],
1695 }],
1696 questions: vec![],
1697 };
1698
1699 let defs = parse_outcome_instruments(&meta).unwrap();
1700 let no = create_instrument_from_def(&defs[1], UnixNanos::default()).unwrap();
1701
1702 let currency = resolve_fee_currency("+UNREGISTERED-TOKEN", Decimal::ZERO, &no)
1705 .expect("zero-fee fallback should succeed");
1706 assert_eq!(currency.code.as_str(), "USDH");
1707
1708 let err = resolve_fee_currency("+UNREGISTERED-TOKEN", dec!(0.01), &no).unwrap_err();
1709 assert!(err.to_string().contains("non-zero fee"));
1710 }
1711
1712 #[rstest]
1713 fn test_parse_outcome_expiry_ns_round_trip() {
1714 let ns = parse_outcome_expiry_ns("20260508-0600").unwrap();
1716 assert_eq!(ns.as_u64(), 1_778_220_000_000_000_000);
1717 }
1718
1719 #[rstest]
1720 #[case("")]
1721 #[case("20260508")]
1722 #[case("20260508-")]
1723 #[case("20260508-0600 ")]
1724 #[case("2026-05-08-06-00")]
1725 #[case("20261308-0600")]
1726 fn test_parse_outcome_expiry_ns_rejects_bad_input(#[case] input: &str) {
1727 assert!(parse_outcome_expiry_ns(input).is_none());
1728 }
1729
1730 #[rstest]
1731 fn test_parse_outcome_instruments_pulls_expiry_from_price_binary() {
1732 let meta = OutcomeMeta {
1733 outcomes: vec![OutcomeMarket {
1734 outcome: 5,
1735 name: "Recurring".to_string(),
1736 description:
1737 "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
1738 .to_string(),
1739 side_specs: vec![
1740 OutcomeSideSpec {
1741 name: "Yes".to_string(),
1742 },
1743 OutcomeSideSpec {
1744 name: "No".to_string(),
1745 },
1746 ],
1747 }],
1748 questions: vec![],
1749 };
1750
1751 let defs = parse_outcome_instruments(&meta).unwrap();
1752 let yes_meta = defs[0].outcome.as_ref().unwrap();
1753 assert_eq!(yes_meta.expiration_ns.as_u64(), 1_778_220_000_000_000_000);
1754 }
1755
1756 #[rstest]
1757 fn test_parse_outcome_instruments_inherits_expiry_from_parent_question() {
1758 let meta = OutcomeMeta {
1762 outcomes: vec![
1763 OutcomeMarket {
1764 outcome: 6,
1765 name: "Recurring Fallback".to_string(),
1766 description: "other".to_string(),
1767 side_specs: vec![],
1768 },
1769 OutcomeMarket {
1770 outcome: 7,
1771 name: "Recurring Named Outcome".to_string(),
1772 description: "index:0".to_string(),
1773 side_specs: vec![],
1774 },
1775 ],
1776 questions: vec![OutcomeQuestion {
1777 question: 0,
1778 name: "Recurring".to_string(),
1779 description:
1780 "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
1781 .to_string(),
1782 fallback_outcome: Some(6),
1783 named_outcomes: vec![7, 8, 9],
1784 settled_named_outcomes: vec![],
1785 }],
1786 };
1787
1788 let defs = parse_outcome_instruments(&meta).unwrap();
1789 let expected_ns: u64 = 1_778_220_000_000_000_000;
1790
1791 for def in &defs {
1792 let outcome = def.outcome.as_ref().unwrap();
1793 assert_eq!(
1794 outcome.expiration_ns.as_u64(),
1795 expected_ns,
1796 "outcome {} side {} should inherit expiry",
1797 outcome.outcome_index,
1798 outcome.outcome_side,
1799 );
1800 }
1801 }
1802
1803 #[rstest]
1804 fn test_derive_outcome_settlements_returns_empty_when_no_questions() {
1805 let meta = OutcomeMeta {
1806 outcomes: vec![],
1807 questions: vec![],
1808 };
1809 assert!(derive_outcome_settlements(&meta).is_empty());
1810 }
1811
1812 #[rstest]
1813 fn test_derive_outcome_settlements_returns_empty_when_no_questions_settled() {
1814 let meta = OutcomeMeta {
1815 outcomes: vec![],
1816 questions: vec![OutcomeQuestion {
1817 question: 0,
1818 name: "Recurring".to_string(),
1819 description: "class:priceBucket|expiry:20260508-0600".to_string(),
1820 fallback_outcome: Some(6),
1821 named_outcomes: vec![7, 8, 9],
1822 settled_named_outcomes: vec![],
1823 }],
1824 };
1825
1826 assert!(derive_outcome_settlements(&meta).is_empty());
1827 }
1828
1829 #[rstest]
1830 fn test_derive_outcome_settlements_marks_winners_losers_and_fallback() {
1831 let meta = OutcomeMeta {
1832 outcomes: vec![],
1833 questions: vec![OutcomeQuestion {
1834 question: 0,
1835 name: "Recurring".to_string(),
1836 description: "class:priceBucket|expiry:20260508-0600".to_string(),
1837 fallback_outcome: Some(6),
1838 named_outcomes: vec![7, 8, 9],
1839 settled_named_outcomes: vec![8],
1840 }],
1841 };
1842
1843 let settlements = derive_outcome_settlements(&meta);
1844 let lookup: ahash::AHashMap<(u32, u8), u8> = settlements
1845 .into_iter()
1846 .map(|s| ((s.outcome_index, s.outcome_side), s.final_value))
1847 .collect();
1848
1849 assert_eq!(lookup[&(8, 0)], 1);
1851 assert_eq!(lookup[&(8, 1)], 0);
1852
1853 for losing in [7, 9, 6] {
1855 assert_eq!(lookup[&(losing, 0)], 0, "outcome {losing} Yes side");
1856 assert_eq!(lookup[&(losing, 1)], 1, "outcome {losing} No side");
1857 }
1858
1859 assert_eq!(lookup.len(), 8);
1860 }
1861
1862 #[rstest]
1863 fn test_parse_outcome_meta_question_settlement_round_trip() {
1864 let json = r#"{
1865 "outcomes": [{"outcome": 5, "name": "Recurring", "description": "class:priceBinary|expiry:20260508-0600", "sideSpecs": []}],
1866 "questions": [{
1867 "question": 0,
1868 "name": "Recurring",
1869 "description": "class:priceBucket|expiry:20260508-0600",
1870 "fallbackOutcome": 6,
1871 "namedOutcomes": [7, 8, 9],
1872 "settledNamedOutcomes": [8]
1873 }]
1874 }"#;
1875
1876 let meta: OutcomeMeta = serde_json::from_str(json).unwrap();
1877 assert_eq!(meta.questions.len(), 1);
1878 let q = &meta.questions[0];
1879 assert_eq!(q.fallback_outcome, Some(6));
1880 assert_eq!(q.named_outcomes, vec![7, 8, 9]);
1881 assert_eq!(q.settled_named_outcomes, vec![8]);
1882
1883 assert!(meta.parent_question(7).is_some());
1884 assert!(meta.parent_question(6).is_some());
1885 assert!(meta.parent_question(99).is_none());
1886 }
1887}