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