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