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