1use anyhow::Context;
17use indexmap::IndexMap;
18use jiff::Timestamp;
19use nautilus_core::{Params, UUID4, UnixNanos, datetime::unix_nanos_to_iso8601};
20use nautilus_model::{
21 data::TradeTick,
22 enums::{
23 AggressorSide, AssetClass, CurrencyType, LiquiditySide, OrderSide, OrderStatus, OrderType,
24 PositionSide, TimeInForce, TriggerType,
25 },
26 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
27 instruments::{BinaryOption, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
28 reports::{FillReport, OrderStatusReport, PositionStatusReport},
29 types::{Currency, Money, Price, Quantity},
30};
31use rust_decimal::Decimal;
32use serde::{Deserialize, Serialize};
33use serde_json::{Value, json};
34use ustr::Ustr;
35
36use super::models::{
37 AssetPosition, HyperliquidFill, HyperliquidRecentTrade, OutcomeMarket, OutcomeMeta,
38 OutcomeQuestion, PerpMeta, SpotBalance, SpotMeta,
39};
40use crate::{
41 common::{
42 consts::{ASSET_INDEX_INFO_KEY, HYPERLIQUID_VENUE},
43 converters::hyperliquid_time_in_force_to_nautilus,
44 enums::{
45 HyperliquidFillDirection, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
46 HyperliquidSide, HyperliquidTimeInForce,
47 },
48 parse::{
49 format_outcome_nautilus_symbol, is_conditional_order_data, make_fill_trade_id,
50 millis_to_nanos, parse_trigger_order_type, parse_trigger_order_type_label,
51 },
52 types::HyperliquidAssetId,
53 },
54 data_types::HyperliquidPublicTrade,
55 websocket::messages::{WsBasicOrderData, WsOrderData},
56};
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60pub enum HyperliquidMarketType {
61 Perp,
63 Spot,
65 Outcome,
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct HyperliquidOutcomeMetadata {
77 pub outcome_index: u32,
79 pub outcome_side: u8,
81 pub market_name: Ustr,
83 pub side_name: Option<Ustr>,
87 pub description: Option<Ustr>,
89 pub activation_ns: UnixNanos,
91 pub expiration_ns: UnixNanos,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub info: Option<Params>,
97}
98
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct HyperliquidInstrumentDef {
105 pub symbol: Ustr,
107 pub raw_symbol: Ustr,
112 pub base: Ustr,
114 pub quote: Ustr,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub settlement: Option<Ustr>,
119 pub market_type: HyperliquidMarketType,
121 pub asset_index: u32,
126 pub price_decimals: u32,
128 pub size_decimals: u32,
130 pub tick_size: Decimal,
132 pub lot_size: Decimal,
134 pub max_leverage: Option<u32>,
136 pub only_isolated: bool,
138 pub is_hip3: bool,
140 pub active: bool,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub outcome: Option<HyperliquidOutcomeMetadata>,
146 pub raw_data: String,
148}
149
150#[must_use]
157fn sanitize_symbol(value: &str) -> std::borrow::Cow<'_, str> {
158 if value.bytes().any(|b| b == b'*' || b == b'?') {
159 let mut out = String::with_capacity(value.len());
160 for ch in value.chars() {
161 out.push(if ch == '*' || ch == '?' { 'x' } else { ch });
162 }
163 std::borrow::Cow::Owned(out)
164 } else {
165 std::borrow::Cow::Borrowed(value)
166 }
167}
168
169pub fn parse_perp_instruments(
183 meta: &PerpMeta,
184 asset_index_base: u32,
185) -> Result<Vec<HyperliquidInstrumentDef>, String> {
186 Ok(parse_perp_instruments_with_settlement(
187 meta,
188 asset_index_base,
189 DEFAULT_PERP_SETTLEMENT_CURRENCY,
190 ))
191}
192
193pub(crate) fn parse_perp_instruments_with_settlement(
194 meta: &PerpMeta,
195 asset_index_base: u32,
196 settlement_currency: &str,
197) -> Vec<HyperliquidInstrumentDef> {
198 const PERP_MAX_DECIMALS: i32 = 6;
199
200 let mut defs = Vec::new();
201
202 for (index, asset) in meta.universe.iter().enumerate() {
203 let is_delisted = asset.is_delisted.unwrap_or(false);
204
205 let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
206 let tick_size = pow10_neg(price_decimals);
207 let lot_size = pow10_neg(asset.sz_decimals);
208
209 let symbol = format!("{}-USD-PERP", sanitize_symbol(&asset.name));
210
211 let raw_symbol: Ustr = asset.name.as_str().into();
212
213 let def = HyperliquidInstrumentDef {
214 symbol: symbol.into(),
215 raw_symbol,
216 base: asset.name.clone().into(),
217 quote: "USD".into(),
218 settlement: Some(settlement_currency.into()),
219 market_type: HyperliquidMarketType::Perp,
220 asset_index: asset_index_base + index as u32,
221 price_decimals,
222 size_decimals: asset.sz_decimals,
223 tick_size,
224 lot_size,
225 max_leverage: asset.max_leverage,
226 only_isolated: asset.only_isolated.unwrap_or(false),
227 is_hip3: asset_index_base > 0,
228 active: !is_delisted,
229 outcome: None,
230 raw_data: serde_json::to_string(asset).unwrap_or_default(),
231 };
232
233 defs.push(def);
234 }
235
236 defs
237}
238
239const DEFAULT_PERP_COLLATERAL_TOKEN: u32 = 0;
240const DEFAULT_PERP_SETTLEMENT_CURRENCY: &str = "USDC";
241
242pub(crate) fn resolve_perp_settlement_currency(
243 meta: &PerpMeta,
244 spot_meta: Option<&SpotMeta>,
245) -> Result<Ustr, String> {
246 let Some(collateral_token) = meta.collateral_token else {
247 return Ok(DEFAULT_PERP_SETTLEMENT_CURRENCY.into());
248 };
249
250 if collateral_token == DEFAULT_PERP_COLLATERAL_TOKEN {
251 return Ok(DEFAULT_PERP_SETTLEMENT_CURRENCY.into());
252 }
253
254 let spot_meta = spot_meta.ok_or_else(|| {
255 format!("Spot metadata required to resolve perp collateral token {collateral_token}")
256 })?;
257 let token = spot_meta
258 .tokens
259 .iter()
260 .find(|token| token.index == collateral_token)
261 .ok_or_else(|| {
262 format!("Perp collateral token index {collateral_token} not found in spot metadata")
263 })?;
264
265 Ok(token.name.as_str().into())
266}
267
268pub 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 let mut pairs = meta.universe.iter().collect::<Vec<_>>();
288 pairs.sort_by(|a, b| {
289 b.is_canonical
290 .cmp(&a.is_canonical)
291 .then(a.index.cmp(&b.index))
292 });
293
294 for pair in pairs {
295 let base_token = tokens_by_index
296 .get(&pair.tokens[0])
297 .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
298 let quote_token = tokens_by_index
299 .get(&pair.tokens[1])
300 .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
301
302 let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
303 let tick_size = pow10_neg(price_decimals);
304 let lot_size = pow10_neg(base_token.sz_decimals);
305
306 let symbol = format!(
307 "{}-{}-SPOT",
308 sanitize_symbol(&base_token.name),
309 sanitize_symbol("e_token.name),
310 );
311
312 let raw_symbol: Ustr = if base_token.name == "PURR" {
316 pair.name.as_str().into()
317 } else {
318 format!("@{}", pair.index).into()
319 };
320
321 let def = HyperliquidInstrumentDef {
322 symbol: symbol.into(),
323 raw_symbol,
324 base: base_token.name.clone().into(),
325 quote: quote_token.name.clone().into(),
326 settlement: None,
327 market_type: HyperliquidMarketType::Spot,
328 asset_index: SPOT_INDEX_OFFSET + pair.index,
329 price_decimals,
330 size_decimals: base_token.sz_decimals,
331 tick_size,
332 lot_size,
333 max_leverage: None,
334 only_isolated: false,
335 is_hip3: false,
336 active: true,
337 outcome: None,
338 raw_data: serde_json::to_string(pair).unwrap_or_default(),
339 };
340
341 defs.push(def);
342 }
343
344 Ok(defs)
345}
346
347pub const OUTCOME_PRICE_DECIMALS: u32 = 5;
351pub const OUTCOME_SIZE_DECIMALS: u32 = 0;
352
353pub(crate) const DEFAULT_OUTCOME_QUOTE_CURRENCY: &str = "USDC";
354
355pub fn parse_outcome_instruments(
373 meta: &OutcomeMeta,
374) -> Result<Vec<HyperliquidInstrumentDef>, String> {
375 let mut defs = Vec::with_capacity(meta.outcomes.len() * 2);
376
377 for market in &meta.outcomes {
378 for side in 0u8..=1u8 {
379 defs.push(build_outcome_def(market, side, meta)?);
380 }
381 }
382
383 Ok(defs)
384}
385
386pub(crate) fn parse_unlisted_outcome_instrument(
387 asset_id: HyperliquidAssetId,
388) -> Result<HyperliquidInstrumentDef, String> {
389 let (Some(outcome), Some(side)) = (asset_id.outcome_index(), asset_id.outcome_side()) else {
390 return Err(format!("Asset {asset_id} is not a HIP-4 outcome"));
391 };
392
393 let market = OutcomeMarket {
395 outcome,
396 name: String::new(),
397 description: String::new(),
398 quote_token: None,
399 side_specs: Vec::new(),
400 };
401
402 let meta = OutcomeMeta {
403 outcomes: Vec::new(),
404 questions: Vec::new(),
405 };
406
407 build_outcome_def(&market, side, &meta)
408}
409
410fn build_outcome_def(
411 market: &OutcomeMarket,
412 side: u8,
413 meta: &OutcomeMeta,
414) -> Result<HyperliquidInstrumentDef, String> {
415 let outcome_index = market.outcome;
416 let asset_id = HyperliquidAssetId::outcome(outcome_index, side);
417 let encoding = asset_id.outcome_encoding().ok_or_else(|| {
418 format!("Invalid outcome encoding for outcome={outcome_index} side={side}")
419 })?;
420
421 let token = format!("+{encoding}");
422 let coin = format!("#{encoding}");
423 let symbol = format_outcome_nautilus_symbol(outcome_index, side);
424
425 let side_name = market
426 .side_specs
427 .get(usize::from(side))
428 .map(|spec| Ustr::from(spec.name.as_str()))
429 .or_else(|| Some(Ustr::from(default_side_label(side))));
430
431 let description = if market.description.is_empty() {
432 None
433 } else {
434 Some(Ustr::from(market.description.as_str()))
435 };
436
437 let parent_question = meta.parent_question(outcome_index);
438 let expiration_ns = resolve_outcome_expiration_ns(market, meta);
439
440 let info = build_outcome_info(
441 market,
442 side,
443 encoding,
444 asset_id.to_raw(),
445 side_name.as_ref().map(Ustr::as_str),
446 parent_question,
447 );
448
449 let outcome_metadata = HyperliquidOutcomeMetadata {
450 outcome_index,
451 outcome_side: side,
452 market_name: Ustr::from(market.name.as_str()),
453 side_name,
454 description,
455 activation_ns: UnixNanos::default(),
456 expiration_ns,
457 info: Some(info),
458 };
459
460 Ok(HyperliquidInstrumentDef {
461 symbol: Ustr::from(symbol.as_str()),
462 raw_symbol: Ustr::from(coin.as_str()),
463 base: Ustr::from(token.as_str()),
464 quote: market
465 .quote_token
466 .as_deref()
467 .unwrap_or(DEFAULT_OUTCOME_QUOTE_CURRENCY)
468 .into(),
469 settlement: None,
470 market_type: HyperliquidMarketType::Outcome,
471 asset_index: asset_id.to_raw(),
472 price_decimals: OUTCOME_PRICE_DECIMALS,
473 size_decimals: OUTCOME_SIZE_DECIMALS,
474 tick_size: pow10_neg(OUTCOME_PRICE_DECIMALS),
475 lot_size: pow10_neg(OUTCOME_SIZE_DECIMALS),
476 max_leverage: None,
477 only_isolated: false,
478 is_hip3: false,
479 active: true,
480 outcome: Some(outcome_metadata),
481 raw_data: serde_json::to_string(market).unwrap_or_default(),
482 })
483}
484
485fn default_side_label(side: u8) -> &'static str {
487 if side == 0 { "Yes" } else { "No" }
488}
489
490fn parse_description_fields(description: &str) -> impl Iterator<Item = (String, String)> + '_ {
494 description
495 .split('|')
496 .filter_map(|piece| piece.split_once(':'))
497 .map(|(key, value)| (camel_to_snake(key.trim()), value.trim().to_string()))
498}
499
500fn camel_to_snake(s: &str) -> String {
501 let mut out = String::with_capacity(s.len() + 4);
502 for (i, ch) in s.char_indices() {
503 if ch.is_ascii_uppercase() {
504 if i > 0 {
505 out.push('_');
506 }
507 out.push(ch.to_ascii_lowercase());
508 } else {
509 out.push(ch);
510 }
511 }
512 out
513}
514
515fn build_outcome_info(
516 market: &OutcomeMarket,
517 side: u8,
518 encoding: u32,
519 asset_id_raw: u32,
520 side_name: Option<&str>,
521 parent_question: Option<&OutcomeQuestion>,
522) -> Params {
523 let mut info = IndexMap::from([
524 ("outcome_index".into(), json!(market.outcome)),
525 ("outcome_side".into(), json!(side)),
526 ]);
527
528 if let Some(name) = side_name {
529 info.insert("side_name".into(), Value::String(name.to_string()));
530 }
531 info.insert("encoding".into(), json!(encoding));
532 info.insert("asset_id".into(), json!(asset_id_raw));
533 info.insert("market_name".into(), Value::String(market.name.clone()));
534
535 for (key, value) in parse_description_fields(&market.description) {
539 match key.as_str() {
540 "index" => {
541 if let Ok(named) = value.parse::<u32>() {
542 info.insert("named_index".into(), json!(named));
543 }
544 }
545 "other" => {
546 info.insert("is_fallback".into(), json!(true));
547 }
548 _ => {
549 info.insert(key, Value::String(value));
550 }
551 }
552 }
553
554 if market.description.trim() == "other" {
558 info.insert("is_fallback".into(), json!(true));
559 }
560
561 if let Some(question) = parent_question {
562 info.insert("question".into(), json!(question.question));
563 info.insert("question_name".into(), Value::String(question.name.clone()));
564 for (key, value) in parse_description_fields(&question.description) {
565 let prefixed = format!("question_{key}");
566 info.insert(prefixed, Value::String(value));
567 }
568 }
569
570 Params::new(info)
571}
572
573fn pow10_neg(decimals: u32) -> Decimal {
574 if decimals == 0 {
575 return Decimal::ONE;
576 }
577
578 Decimal::from_i128_with_scale(1, decimals)
580}
581
582fn resolve_outcome_expiration_ns(market: &OutcomeMarket, meta: &OutcomeMeta) -> UnixNanos {
586 if let Some(ns) = parse_expiry_from_description(&market.description) {
587 return ns;
588 }
589
590 meta.parent_question(market.outcome)
591 .and_then(|q| parse_expiry_from_description(&q.description))
592 .unwrap_or_default()
593}
594
595fn parse_expiry_from_description(description: &str) -> Option<UnixNanos> {
596 description
597 .split('|')
598 .filter_map(|piece| piece.split_once(':'))
599 .find_map(|(key, value)| (key == "expiry").then_some(value))
600 .and_then(parse_outcome_expiry_ns)
601}
602
603fn parse_outcome_expiry_ns(s: &str) -> Option<UnixNanos> {
605 let (date_part, time_part) = s.split_once('-')?;
606 if date_part.len() != 8 || time_part.len() != 4 {
607 return None;
608 }
609
610 let year: i32 = date_part[0..4].parse().ok()?;
611 let month: u32 = date_part[4..6].parse().ok()?;
612 let day: u32 = date_part[6..8].parse().ok()?;
613 let hour: u32 = time_part[0..2].parse().ok()?;
614 let minute: u32 = time_part[2..4].parse().ok()?;
615
616 let datetime = format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:00Z")
617 .parse::<Timestamp>()
618 .ok()?;
619 u64::try_from(datetime.as_nanosecond())
620 .ok()
621 .map(UnixNanos::from)
622}
623
624#[derive(Debug, Clone, Copy, PartialEq, Eq)]
626pub struct OutcomeSettlement {
627 pub outcome_index: u32,
629 pub outcome_side: u8,
631 pub final_value: u8,
633}
634
635#[must_use]
652pub fn derive_outcome_settlements(meta: &OutcomeMeta) -> Vec<OutcomeSettlement> {
653 let mut settlements = Vec::new();
654
655 for question in &meta.questions {
656 if question.settled_named_outcomes.is_empty() {
657 continue;
658 }
659
660 let losing_sides_won = |outcome_index: u32| -> [OutcomeSettlement; 2] {
661 [
663 OutcomeSettlement {
664 outcome_index,
665 outcome_side: 0,
666 final_value: 0,
667 },
668 OutcomeSettlement {
669 outcome_index,
670 outcome_side: 1,
671 final_value: 1,
672 },
673 ]
674 };
675
676 let winning_sides = |outcome_index: u32| -> [OutcomeSettlement; 2] {
677 [
679 OutcomeSettlement {
680 outcome_index,
681 outcome_side: 0,
682 final_value: 1,
683 },
684 OutcomeSettlement {
685 outcome_index,
686 outcome_side: 1,
687 final_value: 0,
688 },
689 ]
690 };
691
692 for outcome_index in &question.named_outcomes {
693 if question.settled_named_outcomes.contains(outcome_index) {
694 settlements.extend(winning_sides(*outcome_index));
695 } else {
696 settlements.extend(losing_sides_won(*outcome_index));
697 }
698 }
699
700 if let Some(fallback) = question.fallback_outcome {
703 settlements.extend(losing_sides_won(fallback));
704 }
705 }
706
707 settlements
708}
709
710pub fn get_currency(code: &str) -> Currency {
711 Currency::try_from_str(code).unwrap_or_else(|| {
712 let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
713 if let Err(e) = Currency::register(currency, false) {
714 log::error!("Failed to register currency '{code}': {e}");
715 }
716 currency
717 })
718}
719
720pub fn get_usdh_currency() -> Currency {
724 Currency::try_from_str("USDH").unwrap_or_else(|| {
725 let currency = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
726 if let Err(e) = Currency::register(currency, false) {
727 log::error!("Failed to register USDH currency: {e}");
728 }
729 currency
730 })
731}
732
733pub fn resolve_fee_currency(
749 fee_token: &str,
750 fee_amount: Decimal,
751 instrument: &dyn Instrument,
752) -> anyhow::Result<Currency> {
753 if is_outcome_side_token(fee_token) {
754 if !fee_amount.is_zero() {
755 anyhow::bail!(
756 "Outcome side token '{fee_token}' carried a non-zero fee {fee_amount}; \
757 venue does not denominate fees in side tokens",
758 );
759 }
760 return Ok(instrument.quote_currency());
761 }
762
763 if let Some(currency) = Currency::try_from_str(fee_token) {
764 return Ok(currency);
765 }
766
767 if fee_amount.is_zero() {
768 let fallback = instrument.quote_currency();
769 log::debug!(
770 "Unregistered fee token '{fee_token}' on zero-fee fill for {}; using {fallback} as fallback",
771 instrument.id(),
772 );
773 return Ok(fallback);
774 }
775
776 anyhow::bail!("Unknown fee token '{fee_token}' with non-zero fee {fee_amount}")
777}
778
779fn is_outcome_side_token(symbol: &str) -> bool {
780 let Some(rest) = symbol.strip_prefix('+') else {
781 return false;
782 };
783 !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
784}
785
786const HYPERLIQUID_MIN_ORDER_NOTIONAL: Decimal = Decimal::TEN;
790
791fn info_with_asset_index(info: Option<IndexMap<String, Value>>, asset_index: u32) -> Params {
798 let mut info = info.unwrap_or_default();
799 info.insert(ASSET_INDEX_INFO_KEY.to_string(), json!(asset_index));
800 Params::new(info)
801}
802
803#[must_use]
811pub fn create_instrument_from_def(
812 def: &HyperliquidInstrumentDef,
813 ts_init: UnixNanos,
814) -> Option<InstrumentAny> {
815 let symbol = Symbol::new(def.symbol);
816 let venue = *HYPERLIQUID_VENUE;
817 let instrument_id = InstrumentId::new(symbol, venue);
818
819 let raw_symbol = Symbol::new(def.raw_symbol);
824 let price_increment = Price::from(def.tick_size.to_string());
825 let size_increment = Quantity::from(def.lot_size.to_string());
826
827 match def.market_type {
828 HyperliquidMarketType::Spot => {
829 let base_currency = get_currency(&def.base);
830 let quote_currency = get_currency(&def.quote);
831 let min_notional = Some(min_order_notional(quote_currency)?);
832 let info = serde_json::from_str::<IndexMap<String, Value>>(&def.raw_data).ok();
833 let info = info_with_asset_index(info, def.asset_index);
834
835 Some(InstrumentAny::CurrencyPair(
836 CurrencyPair::builder()
837 .instrument_id(instrument_id)
838 .raw_symbol(raw_symbol)
839 .base_currency(base_currency)
840 .quote_currency(quote_currency)
841 .price_precision(def.price_decimals as u8)
842 .size_precision(def.size_decimals as u8)
843 .price_increment(price_increment)
844 .size_increment(size_increment)
845 .maybe_min_notional(min_notional)
846 .info(info)
847 .ts_event(ts_init)
849 .ts_init(ts_init)
850 .build()
851 .unwrap(),
852 ))
853 }
854 HyperliquidMarketType::Perp => {
855 let base_currency = get_currency(&def.base);
856 let quote_currency = get_currency(&def.quote);
857 let settlement_code = def
858 .settlement
859 .as_ref()
860 .map_or(DEFAULT_PERP_SETTLEMENT_CURRENCY, Ustr::as_str);
861 let settlement_currency = if settlement_code == "USDH" {
862 get_usdh_currency()
863 } else {
864 get_currency(settlement_code)
865 };
866 let min_notional = Some(min_order_notional(quote_currency)?);
867
868 Some(InstrumentAny::CryptoPerpetual(
869 CryptoPerpetual::builder()
870 .instrument_id(instrument_id)
871 .raw_symbol(raw_symbol)
872 .base_currency(base_currency)
873 .quote_currency(quote_currency)
874 .settlement_currency(settlement_currency)
875 .is_inverse(false)
876 .price_precision(def.price_decimals as u8)
877 .size_precision(def.size_decimals as u8)
878 .price_increment(price_increment)
879 .size_increment(size_increment)
880 .maybe_min_notional(min_notional)
881 .info(info_with_asset_index(None, def.asset_index))
882 .ts_event(ts_init)
884 .ts_init(ts_init)
885 .build()
886 .unwrap(),
887 ))
888 }
889 HyperliquidMarketType::Outcome => {
890 let outcome = def.outcome.as_ref()?;
891 let currency = get_outcome_currency(&def.quote);
892 let info = outcome.info.as_deref().cloned();
893 let info = info_with_asset_index(info, def.asset_index);
894
895 Some(InstrumentAny::BinaryOption(
896 BinaryOption::builder()
897 .instrument_id(instrument_id)
898 .raw_symbol(raw_symbol)
899 .asset_class(AssetClass::Alternative)
900 .currency(currency)
901 .activation_ns(outcome.activation_ns)
902 .expiration_ns(outcome.expiration_ns)
903 .price_precision(def.price_decimals as u8)
904 .size_precision(def.size_decimals as u8)
905 .price_increment(price_increment)
906 .size_increment(size_increment)
907 .maybe_outcome(outcome.side_name)
908 .maybe_description(outcome.description)
909 .info(info)
910 .ts_event(ts_init)
911 .ts_init(ts_init)
912 .build()
913 .unwrap(),
914 ))
915 }
916 }
917}
918
919fn min_order_notional(currency: Currency) -> Option<Money> {
920 Money::from_decimal(HYPERLIQUID_MIN_ORDER_NOTIONAL, currency).ok()
921}
922
923pub(crate) fn get_outcome_currency(code: &str) -> Currency {
924 if code == "USDH" {
925 get_usdh_currency()
926 } else {
927 get_currency(code)
928 }
929}
930
931#[must_use]
934pub fn instruments_from_defs(
935 defs: &[HyperliquidInstrumentDef],
936 ts_init: UnixNanos,
937) -> Vec<InstrumentAny> {
938 defs.iter()
939 .filter_map(|def| create_instrument_from_def(def, ts_init))
940 .collect()
941}
942
943#[must_use]
945pub fn instruments_from_defs_owned(
946 defs: Vec<HyperliquidInstrumentDef>,
947 ts_init: UnixNanos,
948) -> Vec<InstrumentAny> {
949 defs.into_iter()
950 .filter_map(|def| create_instrument_from_def(&def, ts_init))
951 .collect()
952}
953
954fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
955 match side {
956 HyperliquidSide::Buy => OrderSide::Buy,
957 HyperliquidSide::Sell => OrderSide::Sell,
958 }
959}
960
961pub fn parse_order_status_report_from_ws(
967 order_data: &WsOrderData,
968 instrument: &dyn Instrument,
969 account_id: AccountId,
970 ts_init: UnixNanos,
971) -> anyhow::Result<OrderStatusReport> {
972 parse_order_status_report_from_basic(
973 &order_data.order,
974 &order_data.status,
975 instrument,
976 account_id,
977 ts_init,
978 )
979}
980
981pub fn parse_order_status_report_from_basic(
987 order: &WsBasicOrderData,
988 status: &HyperliquidOrderStatusEnum,
989 instrument: &dyn Instrument,
990 account_id: AccountId,
991 ts_init: UnixNanos,
992) -> anyhow::Result<OrderStatusReport> {
993 let instrument_id = instrument.id();
994 let venue_order_id = VenueOrderId::new(order.oid.to_string());
995 let order_side = OrderSide::from(order.side);
996
997 let (tpsl, is_market) = match (order.tpsl, order.order_type.as_deref()) {
1000 (Some(tpsl), _) => (Some(tpsl), order.is_market),
1001 (None, Some(label)) => match parse_trigger_order_type_label(label) {
1002 Some((tpsl, is_market)) => (Some(tpsl), Some(is_market)),
1003 None => (None, order.is_market),
1004 },
1005 (None, None) => (None, order.is_market),
1006 };
1007
1008 let is_conditional = is_conditional_order_data(order.trigger_px, tpsl.as_ref());
1009 let order_type = if is_conditional {
1010 match (is_market, tpsl.as_ref()) {
1011 (Some(is_market), Some(tpsl)) => parse_trigger_order_type(is_market, tpsl),
1012 (None, Some(tpsl)) => parse_trigger_order_type(false, tpsl),
1013 _ => OrderType::Limit,
1014 }
1015 } else {
1016 OrderType::Limit
1017 };
1018
1019 let time_in_force = order
1020 .tif
1021 .map_or(TimeInForce::Gtc, hyperliquid_time_in_force_to_nautilus);
1022 let price_precision = instrument.price_precision();
1023 let size_precision = instrument.size_precision();
1024
1025 let orig_sz = order.orig_sz;
1026 let current_sz = order.sz;
1027
1028 let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
1029 .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
1030 let filled_sz = orig_sz.abs() - current_sz.abs();
1031 let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
1032 .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
1033
1034 let venue_status = OrderStatus::from(*status);
1037 let remainder_canceled = venue_status == OrderStatus::Filled && filled_qty < quantity;
1038 let order_status = if remainder_canceled {
1039 OrderStatus::Canceled
1040 } else {
1041 venue_status
1042 };
1043
1044 let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
1045 let ts_last = ts_accepted;
1046 let report_id = UUID4::new();
1047
1048 let mut report = OrderStatusReport::new(
1049 account_id,
1050 instrument_id,
1051 None, venue_order_id,
1053 order_side.into(),
1054 order_type,
1055 time_in_force,
1056 order_status,
1057 quantity,
1058 filled_qty,
1059 ts_accepted,
1060 ts_last,
1061 ts_init,
1062 Some(report_id),
1063 );
1064
1065 if let Some(cloid) = &order.cloid {
1067 let client_order_id =
1068 ClientOrderId::new_checked(cloid.as_str()).context("invalid order client order ID")?;
1069 report = report.with_client_order_id(client_order_id);
1070 }
1071
1072 if matches!(order.tif, Some(HyperliquidTimeInForce::Alo)) {
1073 report = report.with_post_only(true);
1074 }
1075
1076 if let Some(reduce_only) = order.reduce_only {
1077 report = report.with_reduce_only(reduce_only);
1078 }
1079
1080 if let Some(reason) = status.rejection_reason() {
1081 report = report.with_cancel_reason(reason.to_string());
1082 } else if remainder_canceled {
1083 report = report.with_cancel_reason("Unfilled remainder canceled".to_string());
1084 }
1085
1086 if !matches!(
1090 venue_status,
1091 OrderStatus::Filled | OrderStatus::PartiallyFilled
1092 ) {
1093 let price = Price::from_decimal_dp(order.limit_px, price_precision)
1094 .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
1095 report = report.with_price(price);
1096 }
1097
1098 if is_conditional && let Some(trigger_px) = order.trigger_px {
1099 let trigger_price = Price::from_decimal_dp(trigger_px, price_precision)
1100 .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
1101 report = report
1102 .with_trigger_price(trigger_price)
1103 .with_trigger_type(TriggerType::Default);
1104 }
1105
1106 Ok(report)
1107}
1108
1109pub fn parse_recent_trade(
1122 trade: &HyperliquidRecentTrade,
1123 instrument: &InstrumentAny,
1124) -> anyhow::Result<TradeTick> {
1125 let price = Price::from_decimal_dp(trade.px, instrument.price_precision())
1126 .with_context(|| format!("Failed to create price from '{}'", trade.px))?;
1127
1128 let size = Quantity::from_decimal_dp(trade.sz.abs(), instrument.size_precision())
1129 .with_context(|| format!("Failed to create size from '{}'", trade.sz))?;
1130
1131 let aggressor = AggressorSide::from(trade.side);
1132 let trade_id = TradeId::new_checked(trade.tid.to_string())
1133 .context("invalid trade identifier in Hyperliquid recent trade")?;
1134 let ts_event = millis_to_nanos(trade.time)?;
1135
1136 TradeTick::new_checked(
1137 instrument.id(),
1138 price,
1139 size,
1140 aggressor,
1141 trade_id,
1142 ts_event,
1143 ts_event,
1144 )
1145 .context("failed to construct TradeTick from Hyperliquid recent trade")
1146}
1147
1148pub fn parse_recent_public_trade(
1150 trade: &HyperliquidRecentTrade,
1151 instrument: &InstrumentAny,
1152) -> anyhow::Result<HyperliquidPublicTrade> {
1153 let price = Price::from_decimal_dp(trade.px, instrument.price_precision())
1154 .with_context(|| format!("Failed to create price from '{}'", trade.px))?;
1155 let size = Quantity::from_decimal_dp(trade.sz.abs(), instrument.size_precision())
1156 .with_context(|| format!("Failed to create size from '{}'", trade.sz))?;
1157 let ts_event = millis_to_nanos(trade.time)?;
1158
1159 Ok(HyperliquidPublicTrade::new(
1160 instrument.id(),
1161 price,
1162 size,
1163 AggressorSide::from(trade.side),
1164 trade.tid.to_string(),
1165 trade.users[0].clone(),
1166 trade.users[1].clone(),
1167 trade.hash.clone(),
1168 ts_event,
1169 ts_event,
1170 ))
1171}
1172
1173pub fn filter_recent_public_trades(
1178 trades: Vec<HyperliquidPublicTrade>,
1179 start: Option<UnixNanos>,
1180 end: Option<UnixNanos>,
1181 limit: Option<usize>,
1182 instrument_id: InstrumentId,
1183) -> Vec<HyperliquidPublicTrade> {
1184 let Some(floor) = trades.first().map(|trade| trade.ts_event) else {
1185 return Vec::new();
1186 };
1187
1188 if let Some(end) = end
1189 && end < floor
1190 {
1191 log::warn!(
1192 "Recent public trades for {instrument_id} are entirely older than the requested window; \
1193 snapshot only covers back to {}",
1194 unix_nanos_to_iso8601(floor),
1195 );
1196 return Vec::new();
1197 }
1198
1199 if let Some(start) = start
1200 && start < floor
1201 {
1202 log::warn!(
1203 "Recent public trades for {instrument_id} only cover back to {}; \
1204 the requested start is earlier and cannot be served",
1205 unix_nanos_to_iso8601(floor),
1206 );
1207 }
1208
1209 let mut filtered: Vec<HyperliquidPublicTrade> = trades
1210 .into_iter()
1211 .filter(|trade| start.is_none_or(|value| trade.ts_event >= value))
1212 .filter(|trade| end.is_none_or(|value| trade.ts_event <= value))
1213 .collect();
1214
1215 if let Some(limit) = limit
1216 && filtered.len() > limit
1217 {
1218 filtered.drain(0..filtered.len() - limit);
1220 }
1221
1222 filtered
1223}
1224
1225pub fn parse_fill_report(
1233 fill: &HyperliquidFill,
1234 instrument: &dyn Instrument,
1235 account_id: AccountId,
1236 ts_init: UnixNanos,
1237) -> anyhow::Result<FillReport> {
1238 let instrument_id = instrument.id();
1239 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
1240
1241 if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
1242 log::warn!(
1243 "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
1244 fill.oid,
1245 fill.px,
1246 fill.sz,
1247 );
1248 }
1249
1250 let trade_id = make_fill_trade_id(
1251 &fill.hash,
1252 fill.oid,
1253 fill.px,
1254 fill.sz,
1255 fill.time,
1256 fill.start_position,
1257 );
1258 let order_side = parse_fill_side(&fill.side);
1259
1260 let price_precision = instrument.price_precision();
1261 let size_precision = instrument.size_precision();
1262
1263 let last_px = Price::from_decimal_dp(fill.px, price_precision)
1264 .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
1265 let last_qty = Quantity::from_decimal_dp(fill.sz.abs(), size_precision)
1266 .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
1267
1268 let fee_amount = fill.fee;
1269
1270 let fee_currency = resolve_fee_currency(fill.fee_token.as_str(), fee_amount, instrument)?;
1271 let commission = Money::from_decimal(fee_amount, fee_currency)
1272 .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
1273
1274 let liquidity_side = if fill.crossed {
1276 LiquiditySide::Taker
1277 } else {
1278 LiquiditySide::Maker
1279 };
1280
1281 let ts_event = UnixNanos::from(fill.time * 1_000_000);
1282 let report_id = UUID4::new();
1283 let client_order_id = fill
1284 .cloid
1285 .as_deref()
1286 .map(ClientOrderId::new_checked)
1287 .transpose()
1288 .context("invalid fill client order ID")?;
1289
1290 let report = FillReport::new(
1291 account_id,
1292 instrument_id,
1293 venue_order_id,
1294 trade_id,
1295 order_side,
1296 last_qty,
1297 last_px,
1298 commission,
1299 liquidity_side,
1300 client_order_id,
1301 None, ts_event,
1303 ts_init,
1304 Some(report_id),
1305 );
1306
1307 Ok(report)
1308}
1309
1310pub fn parse_position_status_report(
1316 position_data: &serde_json::Value,
1317 instrument: &dyn Instrument,
1318 account_id: AccountId,
1319 ts_init: UnixNanos,
1320) -> anyhow::Result<PositionStatusReport> {
1321 let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
1323 .context("failed to deserialize AssetPosition")?;
1324
1325 let position = &asset_position.position;
1326 let instrument_id = instrument.id();
1327
1328 let (position_side, quantity_value) = if position.szi.is_zero() {
1330 (PositionSide::Flat, Decimal::ZERO)
1331 } else if position.szi.is_sign_positive() {
1332 (PositionSide::Long, position.szi)
1333 } else {
1334 (PositionSide::Short, position.szi.abs())
1335 };
1336
1337 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1338 .context("failed to create quantity from decimal")?;
1339 let report_id = UUID4::new();
1340 let ts_last = ts_init;
1341 let avg_px_open = position.entry_px;
1342
1343 Ok(PositionStatusReport::new(
1345 account_id,
1346 instrument_id,
1347 position_side,
1348 quantity,
1349 ts_last,
1350 ts_init,
1351 Some(report_id),
1352 None, avg_px_open,
1354 ))
1355}
1356
1357pub fn parse_spot_position_status_report(
1367 balance: &SpotBalance,
1368 instrument: &dyn Instrument,
1369 account_id: AccountId,
1370 ts_init: UnixNanos,
1371) -> anyhow::Result<PositionStatusReport> {
1372 let (position_side, quantity_value) = if balance.total.is_zero() {
1373 (PositionSide::Flat, Decimal::ZERO)
1374 } else {
1375 (PositionSide::Long, balance.total)
1376 };
1377
1378 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1379 .context("failed to create spot quantity from decimal")?;
1380
1381 Ok(PositionStatusReport::new(
1382 account_id,
1383 instrument.id(),
1384 position_side,
1385 quantity,
1386 ts_init,
1387 ts_init,
1388 Some(UUID4::new()),
1389 None,
1390 balance.avg_entry_px(),
1391 ))
1392}
1393
1394#[cfg(test)]
1395mod tests {
1396 use rstest::rstest;
1397 use rust_decimal_macros::dec;
1398
1399 use super::{
1400 super::models::{
1401 HyperliquidL2Book, OutcomeMarket, OutcomeMeta, OutcomeQuestion, OutcomeSideSpec,
1402 PerpAsset, SpotPair, SpotToken,
1403 },
1404 *,
1405 };
1406
1407 #[rstest]
1408 fn test_parse_fill_side() {
1409 assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy,);
1410 assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell,);
1411 }
1412
1413 #[rstest]
1414 #[case::with_cloid(Some("0x0123456789abcdef0123456789abcdef"))]
1415 #[case::without_cloid(None)]
1416 fn test_parse_fill_report_client_order_id(#[case] cloid: Option<&str>) {
1417 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1418 let defs = parse_perp_instruments(&meta, 0).unwrap();
1419 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1420 let mut fills: Vec<HyperliquidFill> = load_test_data("http_query_order_fills.json");
1421 fills[0].cloid = cloid.map(String::from);
1422 let account_id = AccountId::from("HYPERLIQUID-REST");
1423 let ts_init = UnixNanos::from(1_700_000_003_000_000_000);
1424
1425 let report = parse_fill_report(&fills[0], &instrument, account_id, ts_init).unwrap();
1426
1427 assert_eq!(report.client_order_id, cloid.map(ClientOrderId::new));
1428 assert_eq!(report.account_id, account_id);
1429 assert_eq!(report.instrument_id, instrument.id());
1430 assert_eq!(report.venue_order_id, VenueOrderId::from("900029"));
1431 assert_eq!(
1432 report.trade_id,
1433 make_fill_trade_id(
1434 "0xquery-fill-a",
1435 900029,
1436 dec!(90000),
1437 dec!(0.0004),
1438 1_700_000_001_000,
1439 dec!(0)
1440 )
1441 );
1442 assert_eq!(report.order_side, OrderSide::Buy);
1443 assert_eq!(report.last_qty.as_decimal(), dec!(0.0004));
1444 assert_eq!(report.last_px.as_decimal(), dec!(90000));
1445 assert_eq!(report.commission, Money::from("0.010 USDC"));
1446 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1447 assert_eq!(report.venue_position_id, None);
1448 assert_eq!(report.ts_event, UnixNanos::from(1_700_000_001_000_000_000));
1449 assert_eq!(report.ts_init, ts_init);
1450 }
1451
1452 #[rstest]
1453 #[case::empty("")]
1454 #[case::whitespace(" ")]
1455 #[case::non_ascii("0x\u{e9}")]
1456 fn test_parse_fill_report_rejects_invalid_client_order_id(#[case] cloid: &str) {
1457 let instrument = create_btc_perp_instrument();
1458 let mut fills: Vec<HyperliquidFill> = load_test_data("http_query_order_fills.json");
1459 fills[0].cloid = Some(cloid.to_string());
1460
1461 let result = parse_fill_report(
1462 &fills[0],
1463 &instrument,
1464 AccountId::from("HYPERLIQUID-REST"),
1465 UnixNanos::default(),
1466 );
1467
1468 assert_eq!(
1469 result.unwrap_err().to_string(),
1470 "invalid fill client order ID"
1471 );
1472 }
1473
1474 #[rstest]
1475 fn test_pow10_neg() {
1476 assert_eq!(pow10_neg(0), dec!(1));
1477 assert_eq!(pow10_neg(1), dec!(0.1));
1478 assert_eq!(pow10_neg(5), dec!(0.00001));
1479 }
1480
1481 #[rstest]
1482 fn test_parse_perp_instruments() {
1483 let meta = PerpMeta {
1484 universe: vec![
1485 PerpAsset {
1486 name: "BTC".to_string(),
1487 sz_decimals: 5,
1488 max_leverage: Some(50),
1489 ..Default::default()
1490 },
1491 PerpAsset {
1492 name: "DELIST".to_string(),
1493 sz_decimals: 3,
1494 max_leverage: Some(10),
1495 only_isolated: Some(true),
1496 is_delisted: Some(true),
1497 ..Default::default()
1498 },
1499 ],
1500 margin_tables: vec![],
1501 collateral_token: None,
1502 };
1503
1504 let defs = parse_perp_instruments(&meta, 0).unwrap();
1505
1506 assert_eq!(defs.len(), 2);
1508
1509 let btc = &defs[0];
1510 assert_eq!(btc.symbol, "BTC-USD-PERP");
1511 assert_eq!(btc.base, "BTC");
1512 assert_eq!(btc.quote, "USD");
1513 assert_eq!(btc.settlement.as_ref().unwrap(), "USDC");
1514 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1515 assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
1517 assert_eq!(btc.tick_size, dec!(0.1));
1518 assert_eq!(btc.lot_size, dec!(0.00001));
1519 assert_eq!(btc.max_leverage, Some(50));
1520 assert!(!btc.only_isolated);
1521 assert!(btc.active);
1522
1523 let delist = &defs[1];
1524 assert_eq!(delist.symbol, "DELIST-USD-PERP");
1525 assert_eq!(delist.base, "DELIST");
1526 assert!(!delist.active); }
1528
1529 use crate::common::testing::load_test_data;
1530
1531 #[rstest]
1532 fn test_parse_perp_instruments_from_real_data() {
1533 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1534
1535 let defs = parse_perp_instruments(&meta, 0).unwrap();
1536
1537 assert_eq!(defs.len(), 3);
1539
1540 let btc = &defs[0];
1542 assert_eq!(btc.symbol, "BTC-USD-PERP");
1543 assert_eq!(btc.base, "BTC");
1544 assert_eq!(btc.quote, "USD");
1545 assert_eq!(btc.settlement.as_ref().unwrap(), "USDC");
1546 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1547 assert_eq!(btc.size_decimals, 5);
1548 assert_eq!(btc.max_leverage, Some(40));
1549 assert!(btc.active);
1550
1551 let eth = &defs[1];
1553 assert_eq!(eth.symbol, "ETH-USD-PERP");
1554 assert_eq!(eth.base, "ETH");
1555 assert_eq!(eth.size_decimals, 4);
1556 assert_eq!(eth.max_leverage, Some(25));
1557
1558 let atom = &defs[2];
1560 assert_eq!(atom.symbol, "ATOM-USD-PERP");
1561 assert_eq!(atom.base, "ATOM");
1562 assert_eq!(atom.size_decimals, 2);
1563 assert_eq!(atom.max_leverage, Some(5));
1564 }
1565
1566 #[rstest]
1567 fn test_parse_recent_trade() {
1568 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1569 let defs = parse_perp_instruments(&meta, 0).unwrap();
1570 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1571
1572 let trade = HyperliquidRecentTrade {
1573 coin: Ustr::from("BTC"),
1574 side: HyperliquidSide::Sell,
1575 px: dec!(50000.0),
1576 sz: dec!(0.5),
1577 hash: "0xhash".to_string(),
1578 time: 1_769_916_000_000,
1579 tid: 987_654_321,
1580 users: ["0xbuyer".to_string(), "0xseller".to_string()],
1581 };
1582
1583 let tick = parse_recent_trade(&trade, &instrument).unwrap();
1584
1585 assert_eq!(tick.instrument_id, instrument.id());
1586 assert_eq!(tick.price.as_decimal(), dec!(50000));
1587 assert_eq!(tick.size.as_decimal(), dec!(0.5));
1588 assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1589 assert_eq!(tick.trade_id.to_string(), "987654321");
1590 assert_eq!(
1591 tick.ts_event,
1592 UnixNanos::from(1_769_916_000_000 * 1_000_000)
1593 );
1594 assert_eq!(tick.ts_init, tick.ts_event);
1597 }
1598
1599 #[rstest]
1600 fn test_recent_trade_rejects_invalid_price() {
1601 let json = r#"{"coin":"BTC","side":"B","px":"not-a-number","sz":"0.5","time":1769916000000,"tid":1}"#;
1604 assert!(serde_json::from_str::<HyperliquidRecentTrade>(json).is_err());
1605 }
1606
1607 #[rstest]
1608 fn test_create_instrument_from_def_perp_sets_min_notional() {
1609 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1610 let defs = parse_perp_instruments(&meta, 0).unwrap();
1611
1612 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1613
1614 match instrument {
1615 InstrumentAny::CryptoPerpetual(perp) => {
1616 let min_notional = perp.min_notional.unwrap();
1617 assert_eq!(min_notional.currency, Currency::USD());
1618 assert_eq!(min_notional.as_decimal(), dec!(10));
1619 assert_eq!(perp.settlement_currency.code, "USDC");
1620 }
1621 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1622 }
1623 }
1624
1625 #[rstest]
1626 fn test_create_instrument_from_def_carries_asset_index_on_info() {
1627 let perp_meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1631 let outcome_meta = OutcomeMeta {
1632 outcomes: vec![OutcomeMarket {
1633 outcome: 2,
1634 name: "Recurring BTC".to_string(),
1635 description: "Daily settlement".to_string(),
1636 quote_token: Some("USDC".to_string()),
1637 side_specs: vec![
1638 OutcomeSideSpec {
1639 name: "Yes".to_string(),
1640 },
1641 OutcomeSideSpec {
1642 name: "No".to_string(),
1643 },
1644 ],
1645 }],
1646 questions: vec![],
1647 };
1648
1649 let mut defs = parse_perp_instruments(&perp_meta, 0).unwrap();
1650 defs.extend(parse_perp_instruments(&perp_meta, 110_000).unwrap());
1651 defs.extend(parse_outcome_instruments(&outcome_meta).unwrap());
1652
1653 assert!(defs.iter().any(|def| def.is_hip3));
1654
1655 for def in &defs {
1656 let instrument = create_instrument_from_def(def, UnixNanos::default()).unwrap();
1657 let info = instrument
1658 .info()
1659 .unwrap_or_else(|| panic!("info missing for {}", def.symbol));
1660
1661 assert_eq!(
1662 info.get_u64(ASSET_INDEX_INFO_KEY),
1663 Some(u64::from(def.asset_index)),
1664 "asset index mismatch for {}",
1665 def.symbol,
1666 );
1667 }
1668 }
1669
1670 #[rstest]
1671 fn test_parse_perp_instruments_with_non_usdc_collateral() {
1672 let all_metas: Vec<PerpMeta> =
1673 load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1674 let spot_meta: SpotMeta = load_test_data("http_spot_meta_non_usdc_collateral.json");
1675
1676 assert_eq!(all_metas[1].collateral_token, Some(360));
1677 assert_eq!(all_metas[2].collateral_token, Some(235));
1678
1679 let settlement_currency =
1680 resolve_perp_settlement_currency(&all_metas[1], Some(&spot_meta)).unwrap();
1681 let defs = parse_perp_instruments_with_settlement(
1682 &all_metas[1],
1683 110_000,
1684 settlement_currency.as_str(),
1685 );
1686
1687 assert_eq!(settlement_currency, "USDH");
1688 assert_eq!(defs.len(), 1);
1689 assert_eq!(defs[0].symbol, "km:US500-USD-PERP");
1690 assert_eq!(defs[0].quote, "USD");
1691 assert_eq!(defs[0].settlement.as_ref().unwrap(), "USDH");
1692
1693 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1694 match instrument {
1695 InstrumentAny::CryptoPerpetual(perp) => {
1696 assert_eq!(perp.quote_currency.code, "USD");
1697 assert_eq!(perp.settlement_currency.code, "USDH");
1698 assert_eq!(perp.settlement_currency.name, "Hyperliquid USD");
1699 }
1700 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1701 }
1702
1703 let settlement_currency =
1704 resolve_perp_settlement_currency(&all_metas[2], Some(&spot_meta)).unwrap();
1705 let defs = parse_perp_instruments_with_settlement(
1706 &all_metas[2],
1707 140_000,
1708 settlement_currency.as_str(),
1709 );
1710
1711 assert_eq!(settlement_currency, "USDE");
1712 assert_eq!(defs.len(), 1);
1713 assert_eq!(defs[0].symbol, "hyna:BTC-USD-PERP");
1714 assert_eq!(defs[0].quote, "USD");
1715 assert_eq!(defs[0].settlement.as_ref().unwrap(), "USDE");
1716
1717 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1718 match instrument {
1719 InstrumentAny::CryptoPerpetual(perp) => {
1720 assert_eq!(perp.quote_currency.code, "USD");
1721 assert_eq!(perp.settlement_currency.code, "USDE");
1722 }
1723 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1724 }
1725 }
1726
1727 #[rstest]
1728 fn test_create_instrument_from_def_perp_defaults_missing_settlement_to_usdc() {
1729 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1730 let mut defs = parse_perp_instruments(&meta, 0).unwrap();
1731 defs[0].settlement = None;
1732
1733 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1734
1735 match instrument {
1736 InstrumentAny::CryptoPerpetual(perp) => {
1737 assert_eq!(perp.quote_currency.code, "USD");
1738 assert_eq!(perp.settlement_currency.code, "USDC");
1739 }
1740 other => panic!("Expected CryptoPerpetual, was {other:?}"),
1741 }
1742 }
1743
1744 #[rstest]
1745 fn test_resolve_perp_settlement_currency_defaults_to_usdc() {
1746 let legacy_meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1747 let all_metas: Vec<PerpMeta> =
1748 load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1749
1750 let legacy_settlement = resolve_perp_settlement_currency(&legacy_meta, None).unwrap();
1751 let token_zero_settlement = resolve_perp_settlement_currency(&all_metas[0], None).unwrap();
1752
1753 assert_eq!(legacy_settlement, "USDC");
1754 assert_eq!(token_zero_settlement, "USDC");
1755 }
1756
1757 #[rstest]
1758 fn test_resolve_perp_settlement_currency_requires_spot_meta_for_non_usdc() {
1759 let all_metas: Vec<PerpMeta> =
1760 load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1761
1762 let err = resolve_perp_settlement_currency(&all_metas[1], None).unwrap_err();
1763
1764 assert_eq!(
1765 err,
1766 "Spot metadata required to resolve perp collateral token 360",
1767 );
1768 }
1769
1770 #[rstest]
1771 fn test_resolve_perp_settlement_currency_errors_on_missing_token_index() {
1772 let all_metas: Vec<PerpMeta> =
1773 load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1774 let spot_meta = SpotMeta {
1775 tokens: Vec::new(),
1776 universe: Vec::new(),
1777 };
1778
1779 let err = resolve_perp_settlement_currency(&all_metas[1], Some(&spot_meta)).unwrap_err();
1780
1781 assert_eq!(
1782 err,
1783 "Perp collateral token index 360 not found in spot metadata",
1784 );
1785 }
1786
1787 #[rstest]
1788 fn test_deserialize_l2_book_from_real_data() {
1789 let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
1790
1791 assert_eq!(book.coin, "BTC");
1793 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];
1799 let asks = &book.levels[1];
1800
1801 for i in 1..bids.len() {
1803 let prev_price = bids[i - 1].px;
1804 let curr_price = bids[i].px;
1805 assert!(prev_price >= curr_price, "Bids should be descending");
1806 }
1807
1808 for i in 1..asks.len() {
1810 let prev_price = asks[i - 1].px;
1811 let curr_price = asks[i].px;
1812 assert!(prev_price <= curr_price, "Asks should be ascending");
1813 }
1814 }
1815
1816 #[rstest]
1817 fn test_parse_spot_instruments() {
1818 let tokens = vec![
1819 SpotToken {
1820 name: "USDC".to_string(),
1821 sz_decimals: 6,
1822 wei_decimals: 6,
1823 index: 0,
1824 token_id: "0x1".to_string(),
1825 is_canonical: true,
1826 evm_contract: None,
1827 full_name: None,
1828 deployer_trading_fee_share: None,
1829 },
1830 SpotToken {
1831 name: "PURR".to_string(),
1832 sz_decimals: 0,
1833 wei_decimals: 5,
1834 index: 1,
1835 token_id: "0x2".to_string(),
1836 is_canonical: true,
1837 evm_contract: None,
1838 full_name: None,
1839 deployer_trading_fee_share: None,
1840 },
1841 ];
1842
1843 let pairs = vec![
1844 SpotPair {
1845 name: "PURR/USDC".to_string(),
1846 tokens: [1, 0], index: 0,
1848 is_canonical: true,
1849 },
1850 SpotPair {
1851 name: "ALIAS".to_string(),
1852 tokens: [1, 0],
1853 index: 1,
1854 is_canonical: false,
1855 },
1856 ];
1857
1858 let meta = SpotMeta {
1859 tokens,
1860 universe: pairs,
1861 };
1862
1863 let defs = parse_spot_instruments(&meta).unwrap();
1864
1865 assert_eq!(defs.len(), 2);
1866
1867 let purr_usdc = &defs[0];
1868 assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
1869 assert_eq!(purr_usdc.base, "PURR");
1870 assert_eq!(purr_usdc.quote, "USDC");
1871 assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
1872 assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
1874 assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
1875 assert_eq!(purr_usdc.lot_size, dec!(1));
1876 assert_eq!(purr_usdc.max_leverage, None);
1877 assert!(!purr_usdc.only_isolated);
1878 assert!(purr_usdc.active);
1879
1880 let alias = &defs[1];
1881 assert_eq!(alias.symbol, "PURR-USDC-SPOT");
1882 assert_eq!(alias.base, "PURR");
1883 assert!(alias.active);
1884
1885 let instrument = create_instrument_from_def(purr_usdc, UnixNanos::default()).unwrap();
1886
1887 match instrument {
1888 InstrumentAny::CurrencyPair(pair) => {
1889 let min_notional = pair.min_notional.unwrap();
1890 let info = pair.info.unwrap();
1891 assert_eq!(min_notional.currency, Currency::USDC());
1892 assert_eq!(min_notional.as_decimal(), dec!(10));
1893 assert_eq!(info.len(), 5);
1894 assert_eq!(info.get_str("name"), Some("PURR/USDC"));
1895 assert_eq!(info.get("tokens"), Some(&json!([1, 0])));
1896 assert_eq!(info.get_u64("index"), Some(0));
1897 assert_eq!(info.get_bool("isCanonical"), Some(true));
1898 assert_eq!(
1899 info.get_u64(ASSET_INDEX_INFO_KEY),
1900 Some(u64::from(purr_usdc.asset_index)),
1901 );
1902 }
1903 other => panic!("Expected CurrencyPair, was {other:?}"),
1904 }
1905
1906 let instrument = create_instrument_from_def(alias, UnixNanos::default()).unwrap();
1907
1908 match instrument {
1909 InstrumentAny::CurrencyPair(pair) => {
1910 let info = pair.info.unwrap();
1911 assert_eq!(info.len(), 5);
1912 assert_eq!(info.get_str("name"), Some("ALIAS"));
1913 assert_eq!(info.get("tokens"), Some(&json!([1, 0])));
1914 assert_eq!(info.get_u64("index"), Some(1));
1915 assert_eq!(info.get_bool("isCanonical"), Some(false));
1916 assert_eq!(
1917 info.get_u64(ASSET_INDEX_INFO_KEY),
1918 Some(u64::from(alias.asset_index)),
1919 );
1920 }
1921 other => panic!("Expected CurrencyPair, was {other:?}"),
1922 }
1923 }
1924
1925 #[rstest]
1926 fn test_parse_spot_instruments_sorts_canonical_before_non_canonical() {
1927 let tokens = vec![
1931 SpotToken {
1932 name: "USDC".to_string(),
1933 sz_decimals: 6,
1934 wei_decimals: 6,
1935 index: 0,
1936 token_id: "0x1".to_string(),
1937 is_canonical: true,
1938 evm_contract: None,
1939 full_name: None,
1940 deployer_trading_fee_share: None,
1941 },
1942 SpotToken {
1943 name: "HYPE".to_string(),
1944 sz_decimals: 2,
1945 wei_decimals: 8,
1946 index: 150,
1947 token_id: "0x2".to_string(),
1948 is_canonical: true,
1949 evm_contract: None,
1950 full_name: None,
1951 deployer_trading_fee_share: None,
1952 },
1953 ];
1954
1955 let pairs = vec![
1956 SpotPair {
1957 name: "HYPE_OLD".to_string(),
1958 tokens: [150, 0],
1959 index: 3,
1960 is_canonical: false,
1961 },
1962 SpotPair {
1963 name: "HYPE".to_string(),
1964 tokens: [150, 0],
1965 index: 107,
1966 is_canonical: true,
1967 },
1968 ];
1969
1970 let defs = parse_spot_instruments(&SpotMeta {
1971 tokens,
1972 universe: pairs,
1973 })
1974 .unwrap();
1975
1976 assert_eq!(defs.len(), 2);
1977 assert!(defs[0].active);
1978 assert_eq!(defs[0].raw_symbol, "@107");
1979 assert_eq!(defs[0].asset_index, 10000 + 107);
1980 assert!(defs[1].active);
1981 assert_eq!(defs[1].raw_symbol, "@3");
1982 assert_eq!(defs[1].asset_index, 10000 + 3);
1983 }
1984
1985 #[rstest]
1986 fn test_price_decimals_clamping() {
1987 let meta = PerpMeta {
1988 universe: vec![PerpAsset {
1989 name: "HIGHPREC".to_string(),
1990 sz_decimals: 10, max_leverage: Some(1),
1992 ..Default::default()
1993 }],
1994 margin_tables: vec![],
1995 collateral_token: None,
1996 };
1997
1998 let defs = parse_perp_instruments(&meta, 0).unwrap();
1999 assert_eq!(defs[0].price_decimals, 0);
2000 assert_eq!(defs[0].tick_size, dec!(1));
2001 }
2002
2003 #[rstest]
2004 fn test_parse_perp_instruments_hip3_dex() {
2005 let meta = PerpMeta {
2007 universe: vec![
2008 PerpAsset {
2009 name: "xyz:TSLA".to_string(),
2010 sz_decimals: 3,
2011 max_leverage: Some(10),
2012 only_isolated: None,
2013 is_delisted: None,
2014 growth_mode: Some("enabled".to_string()),
2015 margin_mode: Some("strictIsolated".to_string()),
2016 },
2017 PerpAsset {
2018 name: "xyz:NVDA".to_string(),
2019 sz_decimals: 3,
2020 max_leverage: Some(20),
2021 only_isolated: None,
2022 is_delisted: None,
2023 growth_mode: None,
2024 margin_mode: None,
2025 },
2026 ],
2027 margin_tables: vec![],
2028 collateral_token: None,
2029 };
2030
2031 let defs = parse_perp_instruments(&meta, 110_000).unwrap();
2032 assert_eq!(defs.len(), 2);
2033
2034 assert_eq!(defs[0].symbol, "xyz:TSLA-USD-PERP");
2036 assert!(defs[0].symbol.contains(':'));
2037 assert_eq!(defs[0].base, "xyz:TSLA");
2038 assert_eq!(defs[0].asset_index, 110_000);
2039 assert!(defs[0].active);
2040
2041 assert_eq!(defs[1].symbol, "xyz:NVDA-USD-PERP");
2042 assert_eq!(defs[1].asset_index, 110_001);
2043 }
2044
2045 #[rstest]
2046 #[case("BTC", "BTC")]
2047 #[case("kPEPE", "kPEPE")]
2048 #[case("xyz:TSLA", "xyz:TSLA")]
2049 #[case("dex:STREAMABCD****", "dex:STREAMABCDxxxx")]
2050 #[case("ABC?", "ABCx")]
2051 #[case("a*b?c", "axbxc")]
2052 fn test_sanitize_symbol(#[case] input: &str, #[case] expected: &str) {
2053 assert_eq!(sanitize_symbol(input), expected);
2054 }
2055
2056 #[rstest]
2057 fn test_parse_spot_instruments_sanitizes_wildcard_token_names() {
2058 let tokens = vec![
2062 SpotToken {
2063 name: "USDC".to_string(),
2064 sz_decimals: 6,
2065 wei_decimals: 6,
2066 index: 0,
2067 token_id: "0x1".to_string(),
2068 is_canonical: true,
2069 evm_contract: None,
2070 full_name: None,
2071 deployer_trading_fee_share: None,
2072 },
2073 SpotToken {
2074 name: "ABC?".to_string(),
2075 sz_decimals: 4,
2076 wei_decimals: 4,
2077 index: 1,
2078 token_id: "0x2".to_string(),
2079 is_canonical: true,
2080 evm_contract: None,
2081 full_name: None,
2082 deployer_trading_fee_share: None,
2083 },
2084 ];
2085
2086 let pairs = vec![SpotPair {
2087 name: "ABC?/USDC".to_string(),
2088 tokens: [1, 0],
2089 index: 50,
2090 is_canonical: true,
2091 }];
2092
2093 let meta = SpotMeta {
2094 tokens,
2095 universe: pairs,
2096 };
2097
2098 let defs = parse_spot_instruments(&meta).unwrap();
2099 assert_eq!(defs.len(), 1);
2100 assert_eq!(defs[0].symbol, "ABCx-USDC-SPOT");
2101 assert_eq!(defs[0].base, "ABC?");
2102 assert_eq!(defs[0].quote, "USDC");
2103 }
2104
2105 #[rstest]
2106 fn test_parse_perp_instruments_sanitizes_hip3_wildcards() {
2107 let meta = PerpMeta {
2108 universe: vec![PerpAsset {
2109 name: "dex:STREAMABCD****".to_string(),
2110 sz_decimals: 3,
2111 max_leverage: Some(10),
2112 only_isolated: None,
2113 is_delisted: None,
2114 growth_mode: None,
2115 margin_mode: None,
2116 }],
2117 margin_tables: vec![],
2118 collateral_token: None,
2119 };
2120
2121 let defs = parse_perp_instruments(&meta, 110_000).unwrap();
2122 assert_eq!(defs.len(), 1);
2123 assert_eq!(defs[0].symbol, "dex:STREAMABCDxxxx-USD-PERP");
2124 assert_eq!(defs[0].raw_symbol, "dex:STREAMABCD****");
2125 assert_eq!(defs[0].base, "dex:STREAMABCD****");
2126 }
2127
2128 #[rstest]
2129 fn test_parse_outcome_quote_tokens_from_metadata() {
2130 let meta: OutcomeMeta = load_test_data("http_outcome_meta.json");
2131 assert_eq!(meta.outcomes[0].quote_token.as_deref(), Some("USDC"));
2132 assert_eq!(meta.outcomes[1].quote_token.as_deref(), Some("USDH"));
2133 assert_eq!(meta.outcomes[2].quote_token, None);
2134
2135 let defs = parse_outcome_instruments(&meta).unwrap();
2136 assert_eq!(defs.len(), 6);
2137
2138 for (index, currency) in [Currency::USDC(), get_usdh_currency(), Currency::USDC()]
2139 .into_iter()
2140 .enumerate()
2141 {
2142 for side in 0..=1 {
2143 let def = &defs[2 * index + side];
2144 let instrument = create_instrument_from_def(def, UnixNanos::default()).unwrap();
2145 assert_eq!(def.quote, currency.code);
2146 assert_eq!(instrument.quote_currency(), currency);
2147 assert_eq!(instrument.settlement_currency(), currency);
2148 assert_eq!(
2149 instrument.id().symbol.as_str(),
2150 format!(
2151 "{}-{}-OUTCOME",
2152 index + 1,
2153 if side == 0 { "YES" } else { "NO" }
2154 )
2155 );
2156 }
2157 }
2158 }
2159
2160 #[rstest]
2161 fn test_parse_outcome_instruments_emits_both_sides() {
2162 let meta = OutcomeMeta {
2163 outcomes: vec![OutcomeMarket {
2164 outcome: 1,
2165 name: "BTC daily".to_string(),
2166 description: "BTC settles above strike at 06:00 UTC".to_string(),
2167 quote_token: Some("USDC".to_string()),
2168 side_specs: vec![
2169 OutcomeSideSpec {
2170 name: "Yes".to_string(),
2171 },
2172 OutcomeSideSpec {
2173 name: "No".to_string(),
2174 },
2175 ],
2176 }],
2177 questions: vec![],
2178 };
2179
2180 let defs = parse_outcome_instruments(&meta).unwrap();
2181 assert_eq!(defs.len(), 2);
2182
2183 let yes = &defs[0];
2184 assert_eq!(yes.symbol, "1-YES-OUTCOME");
2185 assert_eq!(yes.raw_symbol, "#10");
2186 assert_eq!(yes.market_type, HyperliquidMarketType::Outcome);
2187 assert_eq!(yes.asset_index, 100_000_010);
2188 assert_eq!(yes.price_decimals, OUTCOME_PRICE_DECIMALS);
2189 assert_eq!(yes.size_decimals, OUTCOME_SIZE_DECIMALS);
2190 assert_eq!(yes.tick_size, dec!(0.00001));
2191 assert_eq!(yes.lot_size, dec!(1));
2192 assert_eq!(yes.quote, "USDC");
2193 assert!(yes.active);
2194
2195 let yes_meta = yes.outcome.as_ref().unwrap();
2196 assert_eq!(yes_meta.outcome_index, 1);
2197 assert_eq!(yes_meta.outcome_side, 0);
2198 assert_eq!(yes_meta.market_name, "BTC daily");
2199 assert_eq!(yes_meta.side_name.unwrap(), "Yes");
2200 assert_eq!(
2201 yes_meta.description.unwrap(),
2202 "BTC settles above strike at 06:00 UTC"
2203 );
2204
2205 let no = &defs[1];
2206 assert_eq!(no.symbol, "1-NO-OUTCOME");
2207 assert_eq!(no.raw_symbol, "#11");
2208 assert_eq!(no.asset_index, 100_000_011);
2209 let no_meta = no.outcome.as_ref().unwrap();
2210 assert_eq!(no_meta.outcome_side, 1);
2211 assert_eq!(no_meta.side_name.unwrap(), "No");
2212 }
2213
2214 #[rstest]
2215 fn test_parse_outcome_instruments_handles_missing_side_specs() {
2216 let meta = OutcomeMeta {
2217 outcomes: vec![OutcomeMarket {
2218 outcome: 5,
2219 name: "Recurring".to_string(),
2220 description: String::new(),
2221 quote_token: Some("USDC".to_string()),
2222 side_specs: vec![],
2223 }],
2224 questions: vec![],
2225 };
2226
2227 let defs = parse_outcome_instruments(&meta).unwrap();
2228 assert_eq!(defs.len(), 2);
2229
2230 assert_eq!(defs[0].outcome.as_ref().unwrap().side_name.unwrap(), "Yes");
2234 assert_eq!(defs[1].outcome.as_ref().unwrap().side_name.unwrap(), "No");
2235
2236 for def in &defs {
2237 assert!(def.outcome.as_ref().unwrap().description.is_none());
2238 }
2239
2240 assert_eq!(defs[0].asset_index, 100_000_050);
2241 assert_eq!(defs[1].asset_index, 100_000_051);
2242 }
2243
2244 #[rstest]
2245 #[case::yes(0, "20-YES-OUTCOME", "#200", "+200", 100_000_200, "Yes")]
2246 #[case::no(1, "20-NO-OUTCOME", "#201", "+201", 100_000_201, "No")]
2247 fn test_parse_unlisted_outcome_instrument(
2248 #[case] side: u8,
2249 #[case] symbol: &str,
2250 #[case] raw_symbol: &str,
2251 #[case] base: &str,
2252 #[case] asset_index: u32,
2253 #[case] side_name: &str,
2254 ) {
2255 let listed_meta = OutcomeMeta {
2256 outcomes: vec![OutcomeMarket {
2257 outcome: 20,
2258 name: "Recurring".to_string(),
2259 description: "class:priceBinary|underlying:BTC|expiry:20260511-0600".to_string(),
2260 quote_token: Some("USDC".to_string()),
2261 side_specs: vec![],
2262 }],
2263 questions: vec![],
2264 };
2265
2266 let listed = parse_outcome_instruments(&listed_meta).unwrap();
2267
2268 let def = parse_unlisted_outcome_instrument(HyperliquidAssetId::outcome(20, side)).unwrap();
2269
2270 let listed = &listed[usize::from(side)];
2271 let outcome = def.outcome.as_ref().unwrap();
2272 assert_eq!(def.symbol, symbol);
2273 assert_eq!(def.raw_symbol, raw_symbol);
2274 assert_eq!(def.base, base);
2275 assert_eq!(def.quote, "USDC");
2276 let instrument = create_instrument_from_def(&def, UnixNanos::default()).unwrap();
2277 assert_eq!(instrument.quote_currency(), Currency::USDC());
2278 assert_eq!(instrument.settlement_currency(), Currency::USDC());
2279 assert_eq!(def.market_type, HyperliquidMarketType::Outcome);
2280 assert_eq!(def.asset_index, asset_index);
2281 assert_eq!(def.price_decimals, OUTCOME_PRICE_DECIMALS);
2282 assert_eq!(def.size_decimals, OUTCOME_SIZE_DECIMALS);
2283 assert_eq!(def.tick_size, dec!(0.00001));
2284 assert_eq!(def.lot_size, dec!(1));
2285 assert!(def.active);
2286 assert_eq!(outcome.outcome_index, 20);
2287 assert_eq!(outcome.outcome_side, side);
2288 assert_eq!(outcome.market_name, "");
2289 assert_eq!(outcome.side_name.unwrap(), side_name);
2290 assert_eq!(outcome.description, None);
2291 assert_eq!(outcome.expiration_ns, UnixNanos::default());
2292 assert_eq!(
2293 (def.symbol, def.raw_symbol, def.asset_index),
2294 (listed.symbol, listed.raw_symbol, listed.asset_index),
2295 "an unlisted side must map to the same instrument as its listed form",
2296 );
2297 }
2298
2299 #[rstest]
2300 fn test_parse_unlisted_outcome_instrument_rejects_non_outcome_asset() {
2301 let result = parse_unlisted_outcome_instrument(HyperliquidAssetId::spot(107));
2302
2303 assert_eq!(
2304 result.unwrap_err(),
2305 "Asset 10107 is not a HIP-4 outcome".to_string()
2306 );
2307 }
2308
2309 #[rstest]
2310 fn test_get_usdh_currency_registers_with_explicit_precision() {
2311 let currency = get_usdh_currency();
2312 assert_eq!(currency.code, "USDH");
2313 assert_eq!(currency.precision, 8);
2314 assert_eq!(currency.currency_type, CurrencyType::Crypto);
2315
2316 let again = get_usdh_currency();
2318 assert_eq!(again, currency);
2319 assert!(Currency::try_from_str("USDH").is_some());
2320 }
2321
2322 #[rstest]
2323 fn test_create_instrument_from_def_outcome_emits_binary_option() {
2324 let meta = OutcomeMeta {
2325 outcomes: vec![OutcomeMarket {
2326 outcome: 2,
2327 name: "Recurring BTC".to_string(),
2328 description: "Daily settlement".to_string(),
2329 quote_token: Some("USDC".to_string()),
2330 side_specs: vec![
2331 OutcomeSideSpec {
2332 name: "Yes".to_string(),
2333 },
2334 OutcomeSideSpec {
2335 name: "No".to_string(),
2336 },
2337 ],
2338 }],
2339 questions: vec![],
2340 };
2341
2342 let defs = parse_outcome_instruments(&meta).unwrap();
2343 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2344
2345 match instrument {
2346 InstrumentAny::BinaryOption(bo) => {
2347 assert_eq!(bo.id.symbol.as_str(), "2-YES-OUTCOME");
2348 assert_eq!(bo.raw_symbol.as_str(), "#20");
2349 assert_eq!(bo.asset_class, AssetClass::Alternative);
2350 assert_eq!(bo.currency.code, "USDC");
2351 assert_eq!(bo.price_precision, OUTCOME_PRICE_DECIMALS as u8);
2352 assert_eq!(bo.size_precision, OUTCOME_SIZE_DECIMALS as u8);
2353 assert_eq!(bo.outcome.unwrap(), "Yes");
2354 assert_eq!(bo.description.unwrap(), "Daily settlement");
2355
2356 let info = bo.info.expect("info should be populated for outcomes");
2357 assert_eq!(info.get_u64("outcome_index"), Some(2));
2358 assert_eq!(info.get_u64("outcome_side"), Some(0));
2359 assert_eq!(info.get_u64("encoding"), Some(20));
2360 assert_eq!(info.get_u64("asset_id"), Some(100_000_020));
2361 assert_eq!(info.get_str("side_name"), Some("Yes"));
2362 assert_eq!(info.get_str("market_name"), Some("Recurring BTC"));
2363 }
2364 other => panic!("Expected BinaryOption, was {other:?}"),
2365 }
2366 }
2367
2368 #[rstest]
2369 fn test_create_instrument_from_def_outcome_info_carries_parsed_description() {
2370 let meta = OutcomeMeta {
2371 outcomes: vec![OutcomeMarket {
2372 outcome: 5,
2373 name: "Recurring BTC".to_string(),
2374 description:
2375 "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
2376 .to_string(),
2377 quote_token: Some("USDC".to_string()),
2378 side_specs: vec![
2379 OutcomeSideSpec {
2380 name: "Yes".to_string(),
2381 },
2382 OutcomeSideSpec {
2383 name: "No".to_string(),
2384 },
2385 ],
2386 }],
2387 questions: vec![],
2388 };
2389
2390 let defs = parse_outcome_instruments(&meta).unwrap();
2391 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2392
2393 match yes {
2394 InstrumentAny::BinaryOption(bo) => {
2395 let info = bo.info.expect("info should be populated for outcomes");
2396 assert_eq!(info.get_str("class"), Some("priceBinary"));
2397 assert_eq!(info.get_str("underlying"), Some("BTC"));
2398 assert_eq!(info.get_str("expiry"), Some("20260508-0600"));
2399 assert_eq!(info.get_str("target_price"), Some("81041"));
2400 assert_eq!(info.get_str("period"), Some("1d"));
2401 assert!(info.get("question").is_none());
2402 }
2403 other => panic!("Expected BinaryOption, was {other:?}"),
2404 }
2405 }
2406
2407 #[rstest]
2408 fn test_create_instrument_from_def_outcome_info_merges_parent_question() {
2409 let meta = OutcomeMeta {
2410 outcomes: vec![
2411 OutcomeMarket {
2412 outcome: 6,
2413 name: "Recurring Fallback".to_string(),
2414 description: "other".to_string(),
2415 quote_token: Some("USDC".to_string()),
2416 side_specs: vec![],
2417 },
2418 OutcomeMarket {
2419 outcome: 7,
2420 name: "Recurring Named Outcome".to_string(),
2421 description: "index:0".to_string(),
2422 quote_token: Some("USDC".to_string()),
2423 side_specs: vec![],
2424 },
2425 ],
2426 questions: vec![OutcomeQuestion {
2427 question: 0,
2428 name: "Recurring".to_string(),
2429 description:
2430 "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
2431 .to_string(),
2432 fallback_outcome: Some(6),
2433 named_outcomes: vec![7, 8, 9],
2434 settled_named_outcomes: vec![],
2435 }],
2436 };
2437
2438 let defs = parse_outcome_instruments(&meta).unwrap();
2439
2440 let named = create_instrument_from_def(&defs[2], UnixNanos::default()).unwrap();
2442 match named {
2443 InstrumentAny::BinaryOption(bo) => {
2444 assert_eq!(bo.id.symbol.as_str(), "7-YES-OUTCOME");
2445 let info = bo.info.expect("info should be populated for outcomes");
2446 assert_eq!(info.get_u64("named_index"), Some(0));
2447 assert_eq!(info.get_u64("question"), Some(0));
2448 assert_eq!(info.get_str("question_name"), Some("Recurring"));
2449 assert_eq!(info.get_str("question_class"), Some("priceBucket"));
2450 assert_eq!(info.get_str("question_underlying"), Some("BTC"));
2451 assert_eq!(
2452 info.get_str("question_price_thresholds"),
2453 Some("79303,82540"),
2454 );
2455 assert_eq!(info.get_str("question_expiry"), Some("20260508-0600"));
2456 }
2457 other => panic!("Expected BinaryOption, was {other:?}"),
2458 }
2459
2460 let fallback = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2462 match fallback {
2463 InstrumentAny::BinaryOption(bo) => {
2464 assert_eq!(bo.id.symbol.as_str(), "6-YES-OUTCOME");
2465 let info = bo.info.expect("info should be populated for outcomes");
2466 assert_eq!(info.get_bool("is_fallback"), Some(true));
2467 assert_eq!(info.get_u64("question"), Some(0));
2468 assert_eq!(info.get_str("question_class"), Some("priceBucket"));
2469 }
2470 other => panic!("Expected BinaryOption, was {other:?}"),
2471 }
2472 }
2473
2474 #[rstest]
2475 #[case::usdc_side_token("USDC", "+420", Decimal::ZERO)]
2476 #[case::usdh_side_token("USDH", "+420", Decimal::ZERO)]
2477 #[case::usdc_fee("USDC", "USDC", dec!(0.0345))]
2478 fn test_parse_fill_report_outcome_round_trip(
2479 #[case] quote_token: &str,
2480 #[case] fee_token: &str,
2481 #[case] fee: Decimal,
2482 ) {
2483 let meta = OutcomeMeta {
2484 outcomes: vec![OutcomeMarket {
2485 outcome: 42,
2486 name: "BTC daily".to_string(),
2487 description: "BTC settles above strike at 06:00 UTC".to_string(),
2488 quote_token: Some(quote_token.to_string()),
2489 side_specs: vec![
2490 OutcomeSideSpec {
2491 name: "Yes".to_string(),
2492 },
2493 OutcomeSideSpec {
2494 name: "No".to_string(),
2495 },
2496 ],
2497 }],
2498 questions: vec![],
2499 };
2500
2501 let defs = parse_outcome_instruments(&meta).unwrap();
2502 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2503 assert_eq!(yes.id().symbol.as_str(), "42-YES-OUTCOME");
2504
2505 let fill = HyperliquidFill {
2506 coin: Ustr::from("#420"),
2507 px: dec!(0.5500),
2508 sz: dec!(1000.00),
2509 side: HyperliquidSide::Buy,
2510 time: 1_704_470_400_000,
2511 start_position: dec!(0.00),
2512 dir: HyperliquidFillDirection::OpenLong,
2513 closed_pnl: dec!(0.0),
2514 hash: "0xfeed".to_string(),
2515 oid: 99_001,
2516 crossed: true,
2517 fee,
2518 tid: 77_001,
2519 fee_token: Ustr::from(fee_token),
2520 builder_fee: Some(dec!(0.0001)),
2521 cloid: None,
2522 };
2523
2524 let account_id = AccountId::from("HYPERLIQUID-001");
2525 let report = parse_fill_report(&fill, &yes, account_id, UnixNanos::default()).unwrap();
2526
2527 assert_eq!(
2531 report.commission,
2532 Money::from_decimal(fee, yes.quote_currency()).unwrap()
2533 );
2534 assert_eq!(report.order_side, OrderSide::Buy);
2535 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
2536 assert_eq!(report.last_qty.as_decimal(), dec!(1000));
2537 assert_eq!(report.last_px.as_decimal(), dec!(0.55));
2538 }
2539
2540 #[rstest]
2541 fn test_parse_fill_report_outcome_keeps_venue_price() {
2542 let meta = OutcomeMeta {
2544 outcomes: vec![OutcomeMarket {
2545 outcome: 1473,
2546 name: "Recurring".to_string(),
2547 description: String::new(),
2548 quote_token: Some("USDC".to_string()),
2549 side_specs: vec![],
2550 }],
2551 questions: vec![],
2552 };
2553
2554 let defs = parse_outcome_instruments(&meta).unwrap();
2555 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2556
2557 let fill = HyperliquidFill {
2558 coin: Ustr::from("#14730"),
2559 px: dec!(0.61271),
2560 sz: dec!(112.0),
2561 side: HyperliquidSide::Sell,
2562 time: 1_791_262_678_530,
2563 start_position: dec!(112.0),
2564 dir: HyperliquidFillDirection::Sell,
2565 closed_pnl: dec!(-0.0056),
2566 hash: "0xbeef".to_string(),
2567 oid: 99_002,
2568 crossed: true,
2569 fee: dec!(0.09223001),
2570 tid: 77_002,
2571 fee_token: Ustr::from("USDC"),
2572 builder_fee: None,
2573 cloid: None,
2574 };
2575
2576 let account_id = AccountId::from("HYPERLIQUID-001");
2577 let report = parse_fill_report(&fill, &yes, account_id, UnixNanos::default()).unwrap();
2578
2579 assert_eq!(report.last_px, Price::from("0.61271"));
2580 assert_eq!(report.last_qty, Quantity::from("112"));
2581 assert_eq!(report.last_qty.precision, 0);
2582 }
2583
2584 #[rstest]
2585 fn test_deserialize_user_fills_with_dust_conversion() {
2586 let fills: Vec<HyperliquidFill> = load_test_data("http_user_fills_dust_conversion.json");
2589
2590 let dirs: Vec<HyperliquidFillDirection> = fills.iter().map(|f| f.dir).collect();
2591
2592 assert_eq!(
2593 dirs,
2594 vec![
2595 HyperliquidFillDirection::OpenLong,
2596 HyperliquidFillDirection::CloseShort,
2597 HyperliquidFillDirection::Buy,
2598 HyperliquidFillDirection::SpotDustConversion,
2599 HyperliquidFillDirection::NetChildVaults,
2600 ],
2601 );
2602 }
2603
2604 #[rstest]
2605 fn test_resolve_fee_currency_outcome_token_returns_quote_even_when_registered() {
2606 let meta = OutcomeMeta {
2607 outcomes: vec![OutcomeMarket {
2608 outcome: 88,
2609 name: "Edge".to_string(),
2610 description: String::new(),
2611 quote_token: Some("USDC".to_string()),
2612 side_specs: vec![],
2613 }],
2614 questions: vec![],
2615 };
2616 let defs = parse_outcome_instruments(&meta).unwrap();
2617 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2618
2619 let _ = get_currency("+880");
2622 assert!(Currency::try_from_str("+880").is_some());
2623
2624 let currency = resolve_fee_currency("+880", Decimal::ZERO, &yes)
2625 .expect("zero-fee outcome side token must resolve to quote currency");
2626 assert_eq!(currency.code, "USDC");
2627
2628 let err = resolve_fee_currency("+880", dec!(0.01), &yes).unwrap_err();
2629 let err_msg = err.to_string();
2630 assert!(err_msg.contains("Outcome side token '+880'"));
2631 assert!(err_msg.contains("non-zero fee"));
2632 }
2633
2634 #[rstest]
2635 #[case("+50", true)]
2636 #[case("+0", true)]
2637 #[case("+880", true)]
2638 #[case("", false)]
2639 #[case("+", false)]
2640 #[case("+abc", false)]
2641 #[case("+50a", false)]
2642 #[case("#50", false)]
2643 #[case("USDC", false)]
2644 #[case("-50", false)]
2645 fn test_is_outcome_side_token(#[case] input: &str, #[case] expected: bool) {
2646 assert_eq!(is_outcome_side_token(input), expected);
2647 }
2648
2649 #[rstest]
2650 fn test_resolve_fee_currency_falls_back_to_quote_when_unregistered_and_zero_fee() {
2651 let meta = OutcomeMeta {
2652 outcomes: vec![OutcomeMarket {
2653 outcome: 77,
2654 name: "Edge".to_string(),
2655 description: String::new(),
2656 quote_token: Some("USDC".to_string()),
2657 side_specs: vec![],
2658 }],
2659 questions: vec![],
2660 };
2661
2662 let defs = parse_outcome_instruments(&meta).unwrap();
2663 let no = create_instrument_from_def(&defs[1], UnixNanos::default()).unwrap();
2664
2665 let currency = resolve_fee_currency("+UNREGISTERED-TOKEN", Decimal::ZERO, &no)
2668 .expect("zero-fee fallback should succeed");
2669 assert_eq!(currency.code, "USDC");
2670
2671 let err = resolve_fee_currency("+UNREGISTERED-TOKEN", dec!(0.01), &no).unwrap_err();
2672 assert!(err.to_string().contains("non-zero fee"));
2673 }
2674
2675 #[rstest]
2676 fn test_parse_outcome_expiry_ns_round_trip() {
2677 let ns = parse_outcome_expiry_ns("20260508-0600").unwrap();
2679 assert_eq!(ns.as_u64(), 1_778_220_000_000_000_000);
2680 }
2681
2682 #[rstest]
2683 #[case("")]
2684 #[case("20260508")]
2685 #[case("20260508-")]
2686 #[case("20260508-0600 ")]
2687 #[case("2026-05-08-06-00")]
2688 #[case("20261308-0600")]
2689 fn test_parse_outcome_expiry_ns_rejects_bad_input(#[case] input: &str) {
2690 assert!(parse_outcome_expiry_ns(input).is_none());
2691 }
2692
2693 #[rstest]
2694 fn test_parse_outcome_instruments_pulls_expiry_from_price_binary() {
2695 let meta = OutcomeMeta {
2696 outcomes: vec![OutcomeMarket {
2697 outcome: 5,
2698 name: "Recurring".to_string(),
2699 description:
2700 "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
2701 .to_string(),
2702 quote_token: Some("USDC".to_string()),
2703 side_specs: vec![
2704 OutcomeSideSpec {
2705 name: "Yes".to_string(),
2706 },
2707 OutcomeSideSpec {
2708 name: "No".to_string(),
2709 },
2710 ],
2711 }],
2712 questions: vec![],
2713 };
2714
2715 let defs = parse_outcome_instruments(&meta).unwrap();
2716 let yes_meta = defs[0].outcome.as_ref().unwrap();
2717 assert_eq!(yes_meta.expiration_ns.as_u64(), 1_778_220_000_000_000_000);
2718 }
2719
2720 #[rstest]
2721 fn test_parse_outcome_instruments_inherits_expiry_from_parent_question() {
2722 let meta = OutcomeMeta {
2726 outcomes: vec![
2727 OutcomeMarket {
2728 outcome: 6,
2729 name: "Recurring Fallback".to_string(),
2730 description: "other".to_string(),
2731 quote_token: Some("USDC".to_string()),
2732 side_specs: vec![],
2733 },
2734 OutcomeMarket {
2735 outcome: 7,
2736 name: "Recurring Named Outcome".to_string(),
2737 description: "index:0".to_string(),
2738 quote_token: Some("USDC".to_string()),
2739 side_specs: vec![],
2740 },
2741 ],
2742 questions: vec![OutcomeQuestion {
2743 question: 0,
2744 name: "Recurring".to_string(),
2745 description:
2746 "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
2747 .to_string(),
2748 fallback_outcome: Some(6),
2749 named_outcomes: vec![7, 8, 9],
2750 settled_named_outcomes: vec![],
2751 }],
2752 };
2753
2754 let defs = parse_outcome_instruments(&meta).unwrap();
2755 let expected_ns: u64 = 1_778_220_000_000_000_000;
2756
2757 for def in &defs {
2758 let outcome = def.outcome.as_ref().unwrap();
2759 assert_eq!(
2760 outcome.expiration_ns.as_u64(),
2761 expected_ns,
2762 "outcome {} side {} should inherit expiry",
2763 outcome.outcome_index,
2764 outcome.outcome_side,
2765 );
2766 }
2767 }
2768
2769 #[rstest]
2770 fn test_derive_outcome_settlements_returns_empty_when_no_questions() {
2771 let meta = OutcomeMeta {
2772 outcomes: vec![],
2773 questions: vec![],
2774 };
2775 assert!(derive_outcome_settlements(&meta).is_empty());
2776 }
2777
2778 #[rstest]
2779 fn test_derive_outcome_settlements_returns_empty_when_no_questions_settled() {
2780 let meta = OutcomeMeta {
2781 outcomes: vec![],
2782 questions: vec![OutcomeQuestion {
2783 question: 0,
2784 name: "Recurring".to_string(),
2785 description: "class:priceBucket|expiry:20260508-0600".to_string(),
2786 fallback_outcome: Some(6),
2787 named_outcomes: vec![7, 8, 9],
2788 settled_named_outcomes: vec![],
2789 }],
2790 };
2791
2792 assert!(derive_outcome_settlements(&meta).is_empty());
2793 }
2794
2795 #[rstest]
2796 fn test_derive_outcome_settlements_marks_winners_losers_and_fallback() {
2797 let meta = OutcomeMeta {
2798 outcomes: vec![],
2799 questions: vec![OutcomeQuestion {
2800 question: 0,
2801 name: "Recurring".to_string(),
2802 description: "class:priceBucket|expiry:20260508-0600".to_string(),
2803 fallback_outcome: Some(6),
2804 named_outcomes: vec![7, 8, 9],
2805 settled_named_outcomes: vec![8],
2806 }],
2807 };
2808
2809 let settlements = derive_outcome_settlements(&meta);
2810 let lookup: ahash::AHashMap<(u32, u8), u8> = settlements
2811 .into_iter()
2812 .map(|s| ((s.outcome_index, s.outcome_side), s.final_value))
2813 .collect();
2814
2815 assert_eq!(lookup[&(8, 0)], 1);
2817 assert_eq!(lookup[&(8, 1)], 0);
2818
2819 for losing in [7, 9, 6] {
2821 assert_eq!(lookup[&(losing, 0)], 0, "outcome {losing} Yes side");
2822 assert_eq!(lookup[&(losing, 1)], 1, "outcome {losing} No side");
2823 }
2824
2825 assert_eq!(lookup.len(), 8);
2826 }
2827
2828 #[rstest]
2829 fn test_parse_outcome_meta_question_settlement_round_trip() {
2830 let json = r#"{
2831 "outcomes": [{"outcome": 5, "name": "Recurring", "description": "class:priceBinary|expiry:20260508-0600", "sideSpecs": []}],
2832 "questions": [{
2833 "question": 0,
2834 "name": "Recurring",
2835 "description": "class:priceBucket|expiry:20260508-0600",
2836 "fallbackOutcome": 6,
2837 "namedOutcomes": [7, 8, 9],
2838 "settledNamedOutcomes": [8]
2839 }]
2840 }"#;
2841
2842 let meta: OutcomeMeta = serde_json::from_str(json).unwrap();
2843 assert_eq!(meta.questions.len(), 1);
2844 let q = &meta.questions[0];
2845 assert_eq!(q.fallback_outcome, Some(6));
2846 assert_eq!(q.named_outcomes, vec![7, 8, 9]);
2847 assert_eq!(q.settled_named_outcomes, vec![8]);
2848
2849 assert!(meta.parent_question(7).is_some());
2850 assert!(meta.parent_question(6).is_some());
2851 assert!(meta.parent_question(99).is_none());
2852 }
2853
2854 fn frontend_open_order_row(order_type: &str, is_trigger: bool, trigger_px: &str) -> Value {
2855 json!({
2858 "coin": "BTC",
2859 "side": "A",
2860 "limitPx": "49000.0",
2861 "sz": "0.01",
2862 "oid": 42,
2863 "timestamp": 1_754_000_000_000u64,
2864 "triggerCondition": if is_trigger { "Price below 50000" } else { "N/A" },
2865 "isTrigger": is_trigger,
2866 "triggerPx": trigger_px,
2867 "children": [],
2868 "isPositionTpsl": false,
2869 "reduceOnly": true,
2870 "orderType": order_type,
2871 "origSz": "0.01",
2872 "tif": if is_trigger { Value::Null } else { json!("Gtc") },
2873 "cloid": null
2874 })
2875 }
2876
2877 fn create_btc_perp_instrument() -> InstrumentAny {
2878 let instrument_id = InstrumentId::new(Symbol::new("BTC-PERP"), *HYPERLIQUID_VENUE);
2879
2880 InstrumentAny::CryptoPerpetual(
2881 CryptoPerpetual::builder()
2882 .instrument_id(instrument_id)
2883 .raw_symbol(Symbol::new("BTC-PERP"))
2884 .base_currency(Currency::from("BTC"))
2885 .quote_currency(Currency::from("USDC"))
2886 .settlement_currency(Currency::from("USDC"))
2887 .is_inverse(false)
2888 .price_precision(1)
2889 .size_precision(5)
2890 .price_increment(Price::from("0.1"))
2891 .size_increment(Quantity::from("0.00001"))
2892 .ts_event(UnixNanos::default())
2893 .ts_init(UnixNanos::default())
2894 .build()
2895 .unwrap(),
2896 )
2897 }
2898
2899 #[rstest]
2900 #[case("Stop Market", OrderType::StopMarket)]
2901 #[case("Stop Limit", OrderType::StopLimit)]
2902 #[case("Take Profit Market", OrderType::MarketIfTouched)]
2903 #[case("Take Profit Limit", OrderType::LimitIfTouched)]
2904 fn test_parse_order_status_report_from_frontend_open_order_trigger_label(
2905 #[case] label: &str,
2906 #[case] expected: OrderType,
2907 ) {
2908 let instrument = create_btc_perp_instrument();
2909 let order: WsBasicOrderData =
2910 serde_json::from_value(frontend_open_order_row(label, true, "50000.0")).unwrap();
2911
2912 let report = parse_order_status_report_from_basic(
2913 &order,
2914 &HyperliquidOrderStatusEnum::Open,
2915 &instrument,
2916 AccountId::new("HYPERLIQUID-001"),
2917 UnixNanos::default(),
2918 )
2919 .unwrap();
2920
2921 assert_eq!(report.order_type, expected);
2922 assert_eq!(report.trigger_price, Some(Price::from("50000.0")));
2923 assert_eq!(report.trigger_type, Some(TriggerType::Default));
2924 assert_eq!(report.price, Some(Price::from("49000.0")));
2925 assert!(report.reduce_only);
2926 }
2927
2928 #[rstest]
2929 fn test_parse_order_status_report_from_frontend_open_order_plain_limit() {
2930 let instrument = create_btc_perp_instrument();
2931 let order: WsBasicOrderData =
2932 serde_json::from_value(frontend_open_order_row("Limit", false, "0.0")).unwrap();
2933
2934 let report = parse_order_status_report_from_basic(
2935 &order,
2936 &HyperliquidOrderStatusEnum::Open,
2937 &instrument,
2938 AccountId::new("HYPERLIQUID-001"),
2939 UnixNanos::default(),
2940 )
2941 .unwrap();
2942
2943 assert_eq!(report.order_type, OrderType::Limit);
2944 assert!(report.trigger_price.is_none());
2945 assert!(report.trigger_type.is_none());
2946 }
2947
2948 #[rstest]
2949 #[case::empty("")]
2950 #[case::whitespace(" ")]
2951 #[case::non_ascii("0x\u{e9}")]
2952 fn test_parse_order_status_report_rejects_invalid_client_order_id(#[case] cloid: &str) {
2953 let instrument = create_btc_perp_instrument();
2954 let mut order: WsBasicOrderData =
2955 serde_json::from_value(frontend_open_order_row("Limit", false, "0.0")).unwrap();
2956 order.cloid = Some(cloid.to_string());
2957
2958 let result = parse_order_status_report_from_basic(
2959 &order,
2960 &HyperliquidOrderStatusEnum::Open,
2961 &instrument,
2962 AccountId::from("HYPERLIQUID-REST"),
2963 UnixNanos::default(),
2964 );
2965
2966 assert_eq!(
2967 result.unwrap_err().to_string(),
2968 "invalid order client order ID"
2969 );
2970 }
2971
2972 #[rstest]
2973 #[case("Ioc", "0.0005", OrderStatus::Canceled, "0.0145")]
2974 #[case("Gtc", "0.0005", OrderStatus::Canceled, "0.0145")]
2975 #[case("Ioc", "0.0", OrderStatus::Filled, "0.015")]
2976 #[case("Ioc", "0.000001", OrderStatus::Filled, "0.015")] fn test_parse_order_status_report_from_basic_filled_with_remainder(
2978 #[case] tif: &str,
2979 #[case] sz: &str,
2980 #[case] expected_status: OrderStatus,
2981 #[case] expected_filled: &str,
2982 ) {
2983 let instrument = create_btc_perp_instrument();
2986 let mut row = frontend_open_order_row("Limit", false, "0.0");
2987 row["origSz"] = json!("0.015");
2988 row["sz"] = json!(sz);
2989 row["tif"] = json!(tif);
2990 let order: WsBasicOrderData = serde_json::from_value(row).unwrap();
2991
2992 let report = parse_order_status_report_from_basic(
2993 &order,
2994 &HyperliquidOrderStatusEnum::Filled,
2995 &instrument,
2996 AccountId::new("HYPERLIQUID-001"),
2997 UnixNanos::default(),
2998 )
2999 .unwrap();
3000
3001 assert_eq!(report.order_status, expected_status);
3002 assert_eq!(report.quantity, Quantity::from("0.015"));
3003 assert_eq!(report.filled_qty, Quantity::from(expected_filled));
3004 assert!(report.price.is_none(), "venue-filled rows carry no price");
3005 assert_eq!(
3006 report.cancel_reason.is_some(),
3007 expected_status == OrderStatus::Canceled
3008 );
3009 }
3010}