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