1use anyhow::Context;
17use nautilus_core::{Params, UUID4, UnixNanos};
18use nautilus_model::{
19 enums::{
20 AssetClass, CurrencyType, LiquiditySide, OrderSide, OrderStatus, OrderType,
21 PositionSideSpecified, TimeInForce, TriggerType,
22 },
23 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
24 instruments::{BinaryOption, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
25 reports::{FillReport, OrderStatusReport, PositionStatusReport},
26 types::{Currency, Money, Price, Quantity},
27};
28use rust_decimal::Decimal;
29use serde::{Deserialize, Serialize};
30use serde_json::{Value, json};
31use ustr::Ustr;
32
33use super::models::{
34 AssetPosition, HyperliquidFill, OutcomeMarket, OutcomeMeta, OutcomeQuestion, PerpMeta,
35 SpotBalance, SpotMeta,
36};
37use crate::{
38 common::{
39 consts::HYPERLIQUID_VENUE,
40 enums::{
41 HyperliquidFillDirection, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
42 HyperliquidSide, HyperliquidTimeInForce,
43 },
44 parse::{
45 format_outcome_nautilus_symbol, is_conditional_order_data, make_fill_trade_id,
46 parse_trigger_order_type,
47 },
48 types::HyperliquidAssetId,
49 },
50 websocket::messages::{WsBasicOrderData, WsOrderData},
51};
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55pub enum HyperliquidMarketType {
56 Perp,
58 Spot,
60 Outcome,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct HyperliquidOutcomeMetadata {
72 pub outcome_index: u32,
74 pub outcome_side: u8,
76 pub market_name: Ustr,
78 pub side_name: Option<Ustr>,
82 pub description: Option<Ustr>,
84 pub activation_ns: UnixNanos,
86 pub expiration_ns: UnixNanos,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub info: Option<Params>,
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct HyperliquidInstrumentDef {
100 pub symbol: Ustr,
102 pub raw_symbol: Ustr,
107 pub base: Ustr,
109 pub quote: Ustr,
111 pub market_type: HyperliquidMarketType,
113 pub asset_index: u32,
118 pub price_decimals: u32,
120 pub size_decimals: u32,
122 pub tick_size: Decimal,
124 pub lot_size: Decimal,
126 pub max_leverage: Option<u32>,
128 pub only_isolated: bool,
130 pub is_hip3: bool,
132 pub active: bool,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub outcome: Option<HyperliquidOutcomeMetadata>,
138 pub raw_data: String,
140}
141
142#[must_use]
149fn sanitize_symbol(value: &str) -> std::borrow::Cow<'_, str> {
150 if value.bytes().any(|b| b == b'*' || b == b'?') {
151 let mut out = String::with_capacity(value.len());
152 for ch in value.chars() {
153 out.push(if ch == '*' || ch == '?' { 'x' } else { ch });
154 }
155 std::borrow::Cow::Owned(out)
156 } else {
157 std::borrow::Cow::Borrowed(value)
158 }
159}
160
161pub fn parse_perp_instruments(
175 meta: &PerpMeta,
176 asset_index_base: u32,
177) -> Result<Vec<HyperliquidInstrumentDef>, String> {
178 const PERP_MAX_DECIMALS: i32 = 6;
179
180 let mut defs = Vec::new();
181
182 for (index, asset) in meta.universe.iter().enumerate() {
183 let is_delisted = asset.is_delisted.unwrap_or(false);
184
185 let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
186 let tick_size = pow10_neg(price_decimals);
187 let lot_size = pow10_neg(asset.sz_decimals);
188
189 let symbol = format!("{}-USD-PERP", sanitize_symbol(&asset.name));
190
191 let raw_symbol: Ustr = asset.name.as_str().into();
192
193 let def = HyperliquidInstrumentDef {
194 symbol: symbol.into(),
195 raw_symbol,
196 base: asset.name.clone().into(),
197 quote: "USD".into(),
198 market_type: HyperliquidMarketType::Perp,
199 asset_index: asset_index_base + index as u32,
200 price_decimals,
201 size_decimals: asset.sz_decimals,
202 tick_size,
203 lot_size,
204 max_leverage: asset.max_leverage,
205 only_isolated: asset.only_isolated.unwrap_or(false),
206 is_hip3: asset_index_base > 0,
207 active: !is_delisted,
208 outcome: None,
209 raw_data: serde_json::to_string(asset).unwrap_or_default(),
210 };
211
212 defs.push(def);
213 }
214
215 Ok(defs)
216}
217
218pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
226 const SPOT_MAX_DECIMALS: i32 = 8; const SPOT_INDEX_OFFSET: u32 = 10000; let mut defs = Vec::new();
230
231 let mut tokens_by_index = ahash::AHashMap::new();
233 for token in &meta.tokens {
234 tokens_by_index.insert(token.index, token);
235 }
236
237 for pair in &meta.universe {
238 let base_token = tokens_by_index
242 .get(&pair.tokens[0])
243 .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
244 let quote_token = tokens_by_index
245 .get(&pair.tokens[1])
246 .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
247
248 let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
249 let tick_size = pow10_neg(price_decimals);
250 let lot_size = pow10_neg(base_token.sz_decimals);
251
252 let symbol = format!(
253 "{}-{}-SPOT",
254 sanitize_symbol(&base_token.name),
255 sanitize_symbol("e_token.name),
256 );
257
258 let raw_symbol: Ustr = if base_token.name == "PURR" {
262 pair.name.as_str().into()
263 } else {
264 format!("@{}", pair.index).into()
265 };
266
267 let def = HyperliquidInstrumentDef {
268 symbol: symbol.into(),
269 raw_symbol,
270 base: base_token.name.clone().into(),
271 quote: quote_token.name.clone().into(),
272 market_type: HyperliquidMarketType::Spot,
273 asset_index: SPOT_INDEX_OFFSET + pair.index,
274 price_decimals,
275 size_decimals: base_token.sz_decimals,
276 tick_size,
277 lot_size,
278 max_leverage: None,
279 only_isolated: false,
280 is_hip3: false,
281 active: pair.is_canonical, outcome: None,
283 raw_data: serde_json::to_string(pair).unwrap_or_default(),
284 };
285
286 defs.push(def);
287 }
288
289 defs.sort_by(|a, b| {
294 b.active
295 .cmp(&a.active)
296 .then(a.asset_index.cmp(&b.asset_index))
297 });
298
299 Ok(defs)
300}
301
302pub const OUTCOME_PRICE_DECIMALS: u32 = 4;
308pub const OUTCOME_SIZE_DECIMALS: u32 = 2;
309
310pub fn parse_outcome_instruments(
326 meta: &OutcomeMeta,
327) -> Result<Vec<HyperliquidInstrumentDef>, String> {
328 let mut defs = Vec::with_capacity(meta.outcomes.len() * 2);
329
330 for market in &meta.outcomes {
331 for side in 0u8..=1u8 {
332 defs.push(build_outcome_def(market, side, meta)?);
333 }
334 }
335
336 Ok(defs)
337}
338
339fn build_outcome_def(
340 market: &OutcomeMarket,
341 side: u8,
342 meta: &OutcomeMeta,
343) -> Result<HyperliquidInstrumentDef, String> {
344 let outcome_index = market.outcome;
345 let asset_id = HyperliquidAssetId::outcome(outcome_index, side);
346 let encoding = asset_id.outcome_encoding().ok_or_else(|| {
347 format!("Invalid outcome encoding for outcome={outcome_index} side={side}")
348 })?;
349
350 let token = format!("+{encoding}");
351 let coin = format!("#{encoding}");
352 let symbol = format_outcome_nautilus_symbol(outcome_index, side);
353
354 let side_name = market
355 .side_specs
356 .get(usize::from(side))
357 .map(|spec| Ustr::from(spec.name.as_str()))
358 .or_else(|| Some(Ustr::from(default_side_label(side))));
359
360 let description = if market.description.is_empty() {
361 None
362 } else {
363 Some(Ustr::from(market.description.as_str()))
364 };
365
366 let parent_question = meta.parent_question(outcome_index);
367 let expiration_ns = resolve_outcome_expiration_ns(market, meta);
368
369 let info = build_outcome_info(
370 market,
371 side,
372 encoding,
373 asset_id.to_raw(),
374 side_name.as_ref().map(Ustr::as_str),
375 parent_question,
376 );
377
378 let outcome_metadata = HyperliquidOutcomeMetadata {
379 outcome_index,
380 outcome_side: side,
381 market_name: Ustr::from(market.name.as_str()),
382 side_name,
383 description,
384 activation_ns: UnixNanos::default(),
385 expiration_ns,
386 info: Some(info),
387 };
388
389 Ok(HyperliquidInstrumentDef {
390 symbol: Ustr::from(symbol.as_str()),
391 raw_symbol: Ustr::from(coin.as_str()),
392 base: Ustr::from(token.as_str()),
393 quote: "USDH".into(),
394 market_type: HyperliquidMarketType::Outcome,
395 asset_index: asset_id.to_raw(),
396 price_decimals: OUTCOME_PRICE_DECIMALS,
397 size_decimals: OUTCOME_SIZE_DECIMALS,
398 tick_size: pow10_neg(OUTCOME_PRICE_DECIMALS),
399 lot_size: pow10_neg(OUTCOME_SIZE_DECIMALS),
400 max_leverage: None,
401 only_isolated: false,
402 is_hip3: false,
403 active: true,
404 outcome: Some(outcome_metadata),
405 raw_data: serde_json::to_string(market).unwrap_or_default(),
406 })
407}
408
409fn default_side_label(side: u8) -> &'static str {
411 if side == 0 { "Yes" } else { "No" }
412}
413
414fn parse_description_fields(description: &str) -> impl Iterator<Item = (String, String)> + '_ {
418 description
419 .split('|')
420 .filter_map(|piece| piece.split_once(':'))
421 .map(|(key, value)| (camel_to_snake(key.trim()), value.trim().to_string()))
422}
423
424fn camel_to_snake(s: &str) -> String {
425 let mut out = String::with_capacity(s.len() + 4);
426 for (i, ch) in s.char_indices() {
427 if ch.is_ascii_uppercase() {
428 if i > 0 {
429 out.push('_');
430 }
431 out.push(ch.to_ascii_lowercase());
432 } else {
433 out.push(ch);
434 }
435 }
436 out
437}
438
439fn build_outcome_info(
440 market: &OutcomeMarket,
441 side: u8,
442 encoding: u32,
443 asset_id_raw: u32,
444 side_name: Option<&str>,
445 parent_question: Option<&OutcomeQuestion>,
446) -> Params {
447 let mut info = Params::new();
448
449 info.insert("outcome_index".into(), json!(market.outcome));
450 info.insert("outcome_side".into(), json!(side));
451 if let Some(name) = side_name {
452 info.insert("side_name".into(), Value::String(name.to_string()));
453 }
454 info.insert("encoding".into(), json!(encoding));
455 info.insert("asset_id".into(), json!(asset_id_raw));
456 info.insert("market_name".into(), Value::String(market.name.clone()));
457
458 for (key, value) in parse_description_fields(&market.description) {
462 match key.as_str() {
463 "index" => {
464 if let Ok(named) = value.parse::<u32>() {
465 info.insert("named_index".into(), json!(named));
466 }
467 }
468 "other" => {
469 info.insert("is_fallback".into(), json!(true));
470 }
471 _ => {
472 info.insert(key, Value::String(value));
473 }
474 }
475 }
476
477 if market.description.trim() == "other" {
481 info.insert("is_fallback".into(), json!(true));
482 }
483
484 if let Some(question) = parent_question {
485 info.insert("question".into(), json!(question.question));
486 info.insert("question_name".into(), Value::String(question.name.clone()));
487 for (key, value) in parse_description_fields(&question.description) {
488 let prefixed = format!("question_{key}");
489 info.insert(prefixed, Value::String(value));
490 }
491 }
492
493 info
494}
495
496fn pow10_neg(decimals: u32) -> Decimal {
497 if decimals == 0 {
498 return Decimal::ONE;
499 }
500
501 Decimal::from_i128_with_scale(1, decimals)
503}
504
505fn resolve_outcome_expiration_ns(market: &OutcomeMarket, meta: &OutcomeMeta) -> UnixNanos {
509 if let Some(ns) = parse_expiry_from_description(&market.description) {
510 return ns;
511 }
512
513 meta.parent_question(market.outcome)
514 .and_then(|q| parse_expiry_from_description(&q.description))
515 .unwrap_or_default()
516}
517
518fn parse_expiry_from_description(description: &str) -> Option<UnixNanos> {
519 description
520 .split('|')
521 .filter_map(|piece| piece.split_once(':'))
522 .find_map(|(key, value)| (key == "expiry").then_some(value))
523 .and_then(parse_outcome_expiry_ns)
524}
525
526fn parse_outcome_expiry_ns(s: &str) -> Option<UnixNanos> {
528 let (date_part, time_part) = s.split_once('-')?;
529 if date_part.len() != 8 || time_part.len() != 4 {
530 return None;
531 }
532
533 let year: i32 = date_part[0..4].parse().ok()?;
534 let month: u32 = date_part[4..6].parse().ok()?;
535 let day: u32 = date_part[6..8].parse().ok()?;
536 let hour: u32 = time_part[0..2].parse().ok()?;
537 let minute: u32 = time_part[2..4].parse().ok()?;
538
539 let datetime = chrono::NaiveDate::from_ymd_opt(year, month, day)?
540 .and_hms_opt(hour, minute, 0)?
541 .and_utc();
542 let nanos = datetime.timestamp_nanos_opt()?;
543 u64::try_from(nanos).ok().map(UnixNanos::from)
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub struct OutcomeSettlement {
549 pub outcome_index: u32,
551 pub outcome_side: u8,
553 pub final_value: u8,
555}
556
557#[must_use]
574pub fn derive_outcome_settlements(meta: &OutcomeMeta) -> Vec<OutcomeSettlement> {
575 let mut settlements = Vec::new();
576
577 for question in &meta.questions {
578 if question.settled_named_outcomes.is_empty() {
579 continue;
580 }
581
582 let losing_sides_won = |outcome_index: u32| -> [OutcomeSettlement; 2] {
583 [
585 OutcomeSettlement {
586 outcome_index,
587 outcome_side: 0,
588 final_value: 0,
589 },
590 OutcomeSettlement {
591 outcome_index,
592 outcome_side: 1,
593 final_value: 1,
594 },
595 ]
596 };
597
598 let winning_sides = |outcome_index: u32| -> [OutcomeSettlement; 2] {
599 [
601 OutcomeSettlement {
602 outcome_index,
603 outcome_side: 0,
604 final_value: 1,
605 },
606 OutcomeSettlement {
607 outcome_index,
608 outcome_side: 1,
609 final_value: 0,
610 },
611 ]
612 };
613
614 for outcome_index in &question.named_outcomes {
615 if question.settled_named_outcomes.contains(outcome_index) {
616 settlements.extend(winning_sides(*outcome_index));
617 } else {
618 settlements.extend(losing_sides_won(*outcome_index));
619 }
620 }
621
622 if let Some(fallback) = question.fallback_outcome {
625 settlements.extend(losing_sides_won(fallback));
626 }
627 }
628
629 settlements
630}
631
632pub fn get_currency(code: &str) -> Currency {
633 Currency::try_from_str(code).unwrap_or_else(|| {
634 let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
635 if let Err(e) = Currency::register(currency, false) {
636 log::error!("Failed to register currency '{code}': {e}");
637 }
638 currency
639 })
640}
641
642pub fn get_usdh_currency() -> Currency {
649 Currency::try_from_str("USDH").unwrap_or_else(|| {
650 let currency = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
651 if let Err(e) = Currency::register(currency, false) {
652 log::error!("Failed to register USDH currency: {e}");
653 }
654 currency
655 })
656}
657
658pub fn resolve_fee_currency(
674 fee_token: &str,
675 fee_amount: Decimal,
676 instrument: &dyn Instrument,
677) -> anyhow::Result<Currency> {
678 if is_outcome_side_token(fee_token) {
679 if !fee_amount.is_zero() {
680 anyhow::bail!(
681 "Outcome side token '{fee_token}' carried a non-zero fee {fee_amount}; \
682 venue does not denominate fees in side tokens",
683 );
684 }
685 return Ok(instrument.quote_currency());
686 }
687
688 if let Some(currency) = Currency::try_from_str(fee_token) {
689 return Ok(currency);
690 }
691
692 if fee_amount.is_zero() {
693 let fallback = instrument.quote_currency();
694 log::debug!(
695 "Unregistered fee token '{fee_token}' on zero-fee fill for {}; using {fallback} as fallback",
696 instrument.id(),
697 );
698 return Ok(fallback);
699 }
700
701 anyhow::bail!("Unknown fee token '{fee_token}' with non-zero fee {fee_amount}")
702}
703
704fn is_outcome_side_token(symbol: &str) -> bool {
705 let Some(rest) = symbol.strip_prefix('+') else {
706 return false;
707 };
708 !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
709}
710
711#[must_use]
715pub fn create_instrument_from_def(
716 def: &HyperliquidInstrumentDef,
717 ts_init: UnixNanos,
718) -> Option<InstrumentAny> {
719 let symbol = Symbol::new(def.symbol);
720 let venue = *HYPERLIQUID_VENUE;
721 let instrument_id = InstrumentId::new(symbol, venue);
722
723 let raw_symbol = Symbol::new(def.raw_symbol);
728 let price_increment = Price::from(def.tick_size.to_string());
729 let size_increment = Quantity::from(def.lot_size.to_string());
730
731 match def.market_type {
732 HyperliquidMarketType::Spot => {
733 let base_currency = get_currency(&def.base);
734 let quote_currency = get_currency(&def.quote);
735
736 Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
737 instrument_id,
738 raw_symbol,
739 base_currency,
740 quote_currency,
741 def.price_decimals as u8,
742 def.size_decimals as u8,
743 price_increment,
744 size_increment,
745 None,
746 None,
747 None,
748 None,
749 None,
750 None,
751 None,
752 None,
753 None,
754 None,
755 None,
756 None,
757 None,
758 ts_init, ts_init,
760 )))
761 }
762 HyperliquidMarketType::Perp => {
763 let base_currency = get_currency(&def.base);
764 let quote_currency = get_currency(&def.quote);
765 let settlement_currency = get_currency("USDC");
766
767 Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
768 instrument_id,
769 raw_symbol,
770 base_currency,
771 quote_currency,
772 settlement_currency,
773 false,
774 def.price_decimals as u8,
775 def.size_decimals as u8,
776 price_increment,
777 size_increment,
778 None, None,
780 None,
781 None,
782 None,
783 None,
784 None,
785 None,
786 None,
787 None,
788 None,
789 None,
790 None,
791 ts_init, ts_init,
793 )))
794 }
795 HyperliquidMarketType::Outcome => {
796 let outcome = def.outcome.as_ref()?;
797 let currency = get_usdh_currency();
798
799 Some(InstrumentAny::BinaryOption(BinaryOption::new(
800 instrument_id,
801 raw_symbol,
802 AssetClass::Alternative,
803 currency,
804 outcome.activation_ns,
805 outcome.expiration_ns,
806 def.price_decimals as u8,
807 def.size_decimals as u8,
808 price_increment,
809 size_increment,
810 outcome.side_name,
811 outcome.description,
812 None, None, None, None, None, None, None, None, None, None, outcome.info.clone(),
823 ts_init,
824 ts_init,
825 )))
826 }
827 }
828}
829
830#[must_use]
833pub fn instruments_from_defs(
834 defs: &[HyperliquidInstrumentDef],
835 ts_init: UnixNanos,
836) -> Vec<InstrumentAny> {
837 defs.iter()
838 .filter_map(|def| create_instrument_from_def(def, ts_init))
839 .collect()
840}
841
842#[must_use]
844pub fn instruments_from_defs_owned(
845 defs: Vec<HyperliquidInstrumentDef>,
846 ts_init: UnixNanos,
847) -> Vec<InstrumentAny> {
848 defs.into_iter()
849 .filter_map(|def| create_instrument_from_def(&def, ts_init))
850 .collect()
851}
852
853fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
854 match side {
855 HyperliquidSide::Buy => OrderSide::Buy,
856 HyperliquidSide::Sell => OrderSide::Sell,
857 }
858}
859
860pub fn parse_order_status_report_from_ws(
866 order_data: &WsOrderData,
867 instrument: &dyn Instrument,
868 account_id: AccountId,
869 ts_init: UnixNanos,
870) -> anyhow::Result<OrderStatusReport> {
871 parse_order_status_report_from_basic(
872 &order_data.order,
873 &order_data.status,
874 instrument,
875 account_id,
876 ts_init,
877 )
878}
879
880pub fn parse_order_status_report_from_basic(
886 order: &WsBasicOrderData,
887 status: &HyperliquidOrderStatusEnum,
888 instrument: &dyn Instrument,
889 account_id: AccountId,
890 ts_init: UnixNanos,
891) -> anyhow::Result<OrderStatusReport> {
892 let instrument_id = instrument.id();
893 let venue_order_id = VenueOrderId::new(order.oid.to_string());
894 let order_side = OrderSide::from(order.side);
895
896 let is_conditional =
897 is_conditional_order_data(order.trigger_px.as_deref(), order.tpsl.as_ref());
898 let order_type = if is_conditional {
899 match (order.is_market, order.tpsl.as_ref()) {
900 (Some(is_market), Some(tpsl)) => parse_trigger_order_type(is_market, tpsl),
901 (None, Some(tpsl)) => parse_trigger_order_type(false, tpsl),
902 _ => OrderType::Limit,
903 }
904 } else {
905 OrderType::Limit
906 };
907
908 let time_in_force = match order.tif {
909 Some(HyperliquidTimeInForce::Ioc) => TimeInForce::Ioc,
910 _ => TimeInForce::Gtc,
911 };
912 let order_status = OrderStatus::from(*status);
913
914 let price_precision = instrument.price_precision();
915 let size_precision = instrument.size_precision();
916
917 let orig_sz: Decimal = order
918 .orig_sz
919 .parse()
920 .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
921 let current_sz: Decimal = order
922 .sz
923 .parse()
924 .map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
925
926 let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
927 .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
928 let filled_sz = orig_sz.abs() - current_sz.abs();
929 let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
930 .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
931
932 let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
933 let ts_last = ts_accepted;
934 let report_id = UUID4::new();
935
936 let mut report = OrderStatusReport::new(
937 account_id,
938 instrument_id,
939 None, venue_order_id,
941 order_side,
942 order_type,
943 time_in_force,
944 order_status,
945 quantity,
946 filled_qty,
947 ts_accepted,
948 ts_last,
949 ts_init,
950 Some(report_id),
951 );
952
953 if let Some(cloid) = &order.cloid {
955 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
956 }
957
958 if matches!(order.tif, Some(HyperliquidTimeInForce::Alo)) {
959 report = report.with_post_only(true);
960 }
961
962 if let Some(reduce_only) = order.reduce_only {
963 report = report.with_reduce_only(reduce_only);
964 }
965
966 if !matches!(
970 order_status,
971 OrderStatus::Filled | OrderStatus::PartiallyFilled
972 ) {
973 let limit_px: Decimal = order
974 .limit_px
975 .parse()
976 .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
977 let price = Price::from_decimal_dp(limit_px, price_precision)
978 .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
979 report = report.with_price(price);
980 }
981
982 if is_conditional && let Some(trigger_px) = &order.trigger_px {
983 let trig_px: Decimal = trigger_px
984 .parse()
985 .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
986 let trigger_price = Price::from_decimal_dp(trig_px, price_precision)
987 .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
988 report = report
989 .with_trigger_price(trigger_price)
990 .with_trigger_type(TriggerType::Default);
991 }
992
993 Ok(report)
994}
995
996pub fn parse_fill_report(
1002 fill: &HyperliquidFill,
1003 instrument: &dyn Instrument,
1004 account_id: AccountId,
1005 ts_init: UnixNanos,
1006) -> anyhow::Result<FillReport> {
1007 let instrument_id = instrument.id();
1008 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
1009
1010 if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
1011 log::warn!(
1012 "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
1013 fill.oid,
1014 fill.px,
1015 fill.sz,
1016 );
1017 }
1018
1019 let trade_id = make_fill_trade_id(
1020 &fill.hash,
1021 fill.oid,
1022 &fill.px,
1023 &fill.sz,
1024 fill.time,
1025 &fill.start_position,
1026 );
1027 let order_side = parse_fill_side(&fill.side);
1028
1029 let price_precision = instrument.price_precision();
1030 let size_precision = instrument.size_precision();
1031
1032 let px: Decimal = fill
1033 .px
1034 .parse()
1035 .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
1036 let sz: Decimal = fill
1037 .sz
1038 .parse()
1039 .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
1040
1041 let last_px = Price::from_decimal_dp(px, price_precision)
1042 .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
1043 let last_qty = Quantity::from_decimal_dp(sz.abs(), size_precision)
1044 .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
1045
1046 let fee_amount: Decimal = fill
1047 .fee
1048 .parse()
1049 .map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
1050
1051 let fee_currency = resolve_fee_currency(fill.fee_token.as_str(), fee_amount, instrument)?;
1052 let commission = Money::from_decimal(fee_amount, fee_currency)
1053 .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
1054
1055 let liquidity_side = if fill.crossed {
1057 LiquiditySide::Taker
1058 } else {
1059 LiquiditySide::Maker
1060 };
1061
1062 let ts_event = UnixNanos::from(fill.time * 1_000_000);
1063 let report_id = UUID4::new();
1064
1065 let report = FillReport::new(
1066 account_id,
1067 instrument_id,
1068 venue_order_id,
1069 trade_id,
1070 order_side,
1071 last_qty,
1072 last_px,
1073 commission,
1074 liquidity_side,
1075 None, None, ts_event,
1078 ts_init,
1079 Some(report_id),
1080 );
1081
1082 Ok(report)
1083}
1084
1085pub fn parse_position_status_report(
1091 position_data: &serde_json::Value,
1092 instrument: &dyn Instrument,
1093 account_id: AccountId,
1094 ts_init: UnixNanos,
1095) -> anyhow::Result<PositionStatusReport> {
1096 let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
1098 .context("failed to deserialize AssetPosition")?;
1099
1100 let position = &asset_position.position;
1101 let instrument_id = instrument.id();
1102
1103 let (position_side, quantity_value) = if position.szi.is_zero() {
1105 (PositionSideSpecified::Flat, Decimal::ZERO)
1106 } else if position.szi.is_sign_positive() {
1107 (PositionSideSpecified::Long, position.szi)
1108 } else {
1109 (PositionSideSpecified::Short, position.szi.abs())
1110 };
1111
1112 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1113 .context("failed to create quantity from decimal")?;
1114 let report_id = UUID4::new();
1115 let ts_last = ts_init;
1116 let avg_px_open = position.entry_px;
1117
1118 Ok(PositionStatusReport::new(
1120 account_id,
1121 instrument_id,
1122 position_side,
1123 quantity,
1124 ts_last,
1125 ts_init,
1126 Some(report_id),
1127 None, avg_px_open,
1129 ))
1130}
1131
1132pub fn parse_spot_position_status_report(
1142 balance: &SpotBalance,
1143 instrument: &dyn Instrument,
1144 account_id: AccountId,
1145 ts_init: UnixNanos,
1146) -> anyhow::Result<PositionStatusReport> {
1147 let (position_side, quantity_value) = if balance.total.is_zero() {
1148 (PositionSideSpecified::Flat, Decimal::ZERO)
1149 } else {
1150 (PositionSideSpecified::Long, balance.total)
1151 };
1152
1153 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1154 .context("failed to create spot quantity from decimal")?;
1155
1156 Ok(PositionStatusReport::new(
1157 account_id,
1158 instrument.id(),
1159 position_side,
1160 quantity,
1161 ts_init,
1162 ts_init,
1163 Some(UUID4::new()),
1164 None,
1165 balance.avg_entry_px(),
1166 ))
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use rstest::rstest;
1172 use rust_decimal_macros::dec;
1173
1174 use super::{
1175 super::models::{
1176 HyperliquidL2Book, OutcomeMarket, OutcomeMeta, OutcomeQuestion, OutcomeSideSpec,
1177 PerpAsset, SpotPair, SpotToken,
1178 },
1179 *,
1180 };
1181
1182 #[rstest]
1183 fn test_parse_fill_side() {
1184 assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
1185 assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
1186 }
1187
1188 #[rstest]
1189 fn test_pow10_neg() {
1190 assert_eq!(pow10_neg(0), dec!(1));
1191 assert_eq!(pow10_neg(1), dec!(0.1));
1192 assert_eq!(pow10_neg(5), dec!(0.00001));
1193 }
1194
1195 #[rstest]
1196 fn test_parse_perp_instruments() {
1197 let meta = PerpMeta {
1198 universe: vec![
1199 PerpAsset {
1200 name: "BTC".to_string(),
1201 sz_decimals: 5,
1202 max_leverage: Some(50),
1203 ..Default::default()
1204 },
1205 PerpAsset {
1206 name: "DELIST".to_string(),
1207 sz_decimals: 3,
1208 max_leverage: Some(10),
1209 only_isolated: Some(true),
1210 is_delisted: Some(true),
1211 ..Default::default()
1212 },
1213 ],
1214 margin_tables: vec![],
1215 };
1216
1217 let defs = parse_perp_instruments(&meta, 0).unwrap();
1218
1219 assert_eq!(defs.len(), 2);
1221
1222 let btc = &defs[0];
1223 assert_eq!(btc.symbol, "BTC-USD-PERP");
1224 assert_eq!(btc.base, "BTC");
1225 assert_eq!(btc.quote, "USD");
1226 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1227 assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
1229 assert_eq!(btc.tick_size, dec!(0.1));
1230 assert_eq!(btc.lot_size, dec!(0.00001));
1231 assert_eq!(btc.max_leverage, Some(50));
1232 assert!(!btc.only_isolated);
1233 assert!(btc.active);
1234
1235 let delist = &defs[1];
1236 assert_eq!(delist.symbol, "DELIST-USD-PERP");
1237 assert_eq!(delist.base, "DELIST");
1238 assert!(!delist.active); }
1240
1241 use crate::common::testing::load_test_data;
1242
1243 #[rstest]
1244 fn test_parse_perp_instruments_from_real_data() {
1245 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1246
1247 let defs = parse_perp_instruments(&meta, 0).unwrap();
1248
1249 assert_eq!(defs.len(), 3);
1251
1252 let btc = &defs[0];
1254 assert_eq!(btc.symbol, "BTC-USD-PERP");
1255 assert_eq!(btc.base, "BTC");
1256 assert_eq!(btc.quote, "USD");
1257 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1258 assert_eq!(btc.size_decimals, 5);
1259 assert_eq!(btc.max_leverage, Some(40));
1260 assert!(btc.active);
1261
1262 let eth = &defs[1];
1264 assert_eq!(eth.symbol, "ETH-USD-PERP");
1265 assert_eq!(eth.base, "ETH");
1266 assert_eq!(eth.size_decimals, 4);
1267 assert_eq!(eth.max_leverage, Some(25));
1268
1269 let atom = &defs[2];
1271 assert_eq!(atom.symbol, "ATOM-USD-PERP");
1272 assert_eq!(atom.base, "ATOM");
1273 assert_eq!(atom.size_decimals, 2);
1274 assert_eq!(atom.max_leverage, Some(5));
1275 }
1276
1277 #[rstest]
1278 fn test_deserialize_l2_book_from_real_data() {
1279 let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
1280
1281 assert_eq!(book.coin, "BTC");
1283 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];
1289 let asks = &book.levels[1];
1290
1291 for i in 1..bids.len() {
1293 let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
1294 let curr_price = bids[i].px.parse::<f64>().unwrap();
1295 assert!(prev_price >= curr_price, "Bids should be descending");
1296 }
1297
1298 for i in 1..asks.len() {
1300 let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
1301 let curr_price = asks[i].px.parse::<f64>().unwrap();
1302 assert!(prev_price <= curr_price, "Asks should be ascending");
1303 }
1304 }
1305
1306 #[rstest]
1307 fn test_parse_spot_instruments() {
1308 let tokens = vec![
1309 SpotToken {
1310 name: "USDC".to_string(),
1311 sz_decimals: 6,
1312 wei_decimals: 6,
1313 index: 0,
1314 token_id: "0x1".to_string(),
1315 is_canonical: true,
1316 evm_contract: None,
1317 full_name: None,
1318 deployer_trading_fee_share: None,
1319 },
1320 SpotToken {
1321 name: "PURR".to_string(),
1322 sz_decimals: 0,
1323 wei_decimals: 5,
1324 index: 1,
1325 token_id: "0x2".to_string(),
1326 is_canonical: true,
1327 evm_contract: None,
1328 full_name: None,
1329 deployer_trading_fee_share: None,
1330 },
1331 ];
1332
1333 let pairs = vec![
1334 SpotPair {
1335 name: "PURR/USDC".to_string(),
1336 tokens: [1, 0], index: 0,
1338 is_canonical: true,
1339 },
1340 SpotPair {
1341 name: "ALIAS".to_string(),
1342 tokens: [1, 0],
1343 index: 1,
1344 is_canonical: false, },
1346 ];
1347
1348 let meta = SpotMeta {
1349 tokens,
1350 universe: pairs,
1351 };
1352
1353 let defs = parse_spot_instruments(&meta).unwrap();
1354
1355 assert_eq!(defs.len(), 2);
1357
1358 let purr_usdc = &defs[0];
1359 assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
1360 assert_eq!(purr_usdc.base, "PURR");
1361 assert_eq!(purr_usdc.quote, "USDC");
1362 assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
1363 assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
1365 assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
1366 assert_eq!(purr_usdc.lot_size, dec!(1));
1367 assert_eq!(purr_usdc.max_leverage, None);
1368 assert!(!purr_usdc.only_isolated);
1369 assert!(purr_usdc.active);
1370
1371 let alias = &defs[1];
1372 assert_eq!(alias.symbol, "PURR-USDC-SPOT");
1373 assert_eq!(alias.base, "PURR");
1374 assert!(!alias.active); }
1376
1377 #[rstest]
1378 fn test_parse_spot_instruments_sorts_canonical_before_non_canonical() {
1379 let tokens = vec![
1383 SpotToken {
1384 name: "USDC".to_string(),
1385 sz_decimals: 6,
1386 wei_decimals: 6,
1387 index: 0,
1388 token_id: "0x1".to_string(),
1389 is_canonical: true,
1390 evm_contract: None,
1391 full_name: None,
1392 deployer_trading_fee_share: None,
1393 },
1394 SpotToken {
1395 name: "HYPE".to_string(),
1396 sz_decimals: 2,
1397 wei_decimals: 8,
1398 index: 150,
1399 token_id: "0x2".to_string(),
1400 is_canonical: true,
1401 evm_contract: None,
1402 full_name: None,
1403 deployer_trading_fee_share: None,
1404 },
1405 ];
1406
1407 let pairs = vec![
1408 SpotPair {
1409 name: "HYPE_OLD".to_string(),
1410 tokens: [150, 0],
1411 index: 3,
1412 is_canonical: false,
1413 },
1414 SpotPair {
1415 name: "HYPE".to_string(),
1416 tokens: [150, 0],
1417 index: 107,
1418 is_canonical: true,
1419 },
1420 ];
1421
1422 let defs = parse_spot_instruments(&SpotMeta {
1423 tokens,
1424 universe: pairs,
1425 })
1426 .unwrap();
1427
1428 assert_eq!(defs.len(), 2);
1429 assert!(defs[0].active, "canonical must sort first");
1430 assert_eq!(defs[0].asset_index, 10000 + 107);
1431 assert!(!defs[1].active);
1432 assert_eq!(defs[1].asset_index, 10000 + 3);
1433 }
1434
1435 #[rstest]
1436 fn test_price_decimals_clamping() {
1437 let meta = PerpMeta {
1438 universe: vec![PerpAsset {
1439 name: "HIGHPREC".to_string(),
1440 sz_decimals: 10, max_leverage: Some(1),
1442 ..Default::default()
1443 }],
1444 margin_tables: vec![],
1445 };
1446
1447 let defs = parse_perp_instruments(&meta, 0).unwrap();
1448 assert_eq!(defs[0].price_decimals, 0);
1449 assert_eq!(defs[0].tick_size, dec!(1));
1450 }
1451
1452 #[rstest]
1453 fn test_parse_perp_instruments_hip3_dex() {
1454 let meta = PerpMeta {
1456 universe: vec![
1457 PerpAsset {
1458 name: "xyz:TSLA".to_string(),
1459 sz_decimals: 3,
1460 max_leverage: Some(10),
1461 only_isolated: None,
1462 is_delisted: None,
1463 growth_mode: Some("enabled".to_string()),
1464 margin_mode: Some("strictIsolated".to_string()),
1465 },
1466 PerpAsset {
1467 name: "xyz:NVDA".to_string(),
1468 sz_decimals: 3,
1469 max_leverage: Some(20),
1470 only_isolated: None,
1471 is_delisted: None,
1472 growth_mode: None,
1473 margin_mode: None,
1474 },
1475 ],
1476 margin_tables: vec![],
1477 };
1478
1479 let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1480 assert_eq!(defs.len(), 2);
1481
1482 assert_eq!(defs[0].symbol, "xyz:TSLA-USD-PERP");
1484 assert!(defs[0].symbol.contains(':'));
1485 assert_eq!(defs[0].base, "xyz:TSLA");
1486 assert_eq!(defs[0].asset_index, 110_000);
1487 assert!(defs[0].active);
1488
1489 assert_eq!(defs[1].symbol, "xyz:NVDA-USD-PERP");
1490 assert_eq!(defs[1].asset_index, 110_001);
1491 }
1492
1493 #[rstest]
1494 #[case("BTC", "BTC")]
1495 #[case("kPEPE", "kPEPE")]
1496 #[case("xyz:TSLA", "xyz:TSLA")]
1497 #[case("dex:STREAMABCD****", "dex:STREAMABCDxxxx")]
1498 #[case("ABC?", "ABCx")]
1499 #[case("a*b?c", "axbxc")]
1500 fn test_sanitize_symbol(#[case] input: &str, #[case] expected: &str) {
1501 assert_eq!(sanitize_symbol(input), expected);
1502 }
1503
1504 #[rstest]
1505 fn test_parse_spot_instruments_sanitizes_wildcard_token_names() {
1506 let tokens = vec![
1510 SpotToken {
1511 name: "USDC".to_string(),
1512 sz_decimals: 6,
1513 wei_decimals: 6,
1514 index: 0,
1515 token_id: "0x1".to_string(),
1516 is_canonical: true,
1517 evm_contract: None,
1518 full_name: None,
1519 deployer_trading_fee_share: None,
1520 },
1521 SpotToken {
1522 name: "ABC?".to_string(),
1523 sz_decimals: 4,
1524 wei_decimals: 4,
1525 index: 1,
1526 token_id: "0x2".to_string(),
1527 is_canonical: true,
1528 evm_contract: None,
1529 full_name: None,
1530 deployer_trading_fee_share: None,
1531 },
1532 ];
1533
1534 let pairs = vec![SpotPair {
1535 name: "ABC?/USDC".to_string(),
1536 tokens: [1, 0],
1537 index: 50,
1538 is_canonical: true,
1539 }];
1540
1541 let meta = SpotMeta {
1542 tokens,
1543 universe: pairs,
1544 };
1545
1546 let defs = parse_spot_instruments(&meta).unwrap();
1547 assert_eq!(defs.len(), 1);
1548 assert_eq!(defs[0].symbol, "ABCx-USDC-SPOT");
1549 assert_eq!(defs[0].base, "ABC?");
1550 assert_eq!(defs[0].quote, "USDC");
1551 }
1552
1553 #[rstest]
1554 fn test_parse_perp_instruments_sanitizes_hip3_wildcards() {
1555 let meta = PerpMeta {
1556 universe: vec![PerpAsset {
1557 name: "dex:STREAMABCD****".to_string(),
1558 sz_decimals: 3,
1559 max_leverage: Some(10),
1560 only_isolated: None,
1561 is_delisted: None,
1562 growth_mode: None,
1563 margin_mode: None,
1564 }],
1565 margin_tables: vec![],
1566 };
1567
1568 let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1569 assert_eq!(defs.len(), 1);
1570 assert_eq!(defs[0].symbol, "dex:STREAMABCDxxxx-USD-PERP");
1571 assert_eq!(defs[0].raw_symbol.as_str(), "dex:STREAMABCD****");
1572 assert_eq!(defs[0].base.as_str(), "dex:STREAMABCD****");
1573 }
1574
1575 #[rstest]
1576 fn test_parse_outcome_instruments_emits_both_sides() {
1577 let meta = OutcomeMeta {
1578 outcomes: vec![OutcomeMarket {
1579 outcome: 1,
1580 name: "BTC daily".to_string(),
1581 description: "BTC settles above strike at 06:00 UTC".to_string(),
1582 side_specs: vec![
1583 OutcomeSideSpec {
1584 name: "Yes".to_string(),
1585 },
1586 OutcomeSideSpec {
1587 name: "No".to_string(),
1588 },
1589 ],
1590 }],
1591 questions: vec![],
1592 };
1593
1594 let defs = parse_outcome_instruments(&meta).unwrap();
1595 assert_eq!(defs.len(), 2);
1596
1597 let yes = &defs[0];
1598 assert_eq!(yes.symbol.as_str(), "1-YES-OUTCOME");
1599 assert_eq!(yes.raw_symbol.as_str(), "#10");
1600 assert_eq!(yes.market_type, HyperliquidMarketType::Outcome);
1601 assert_eq!(yes.asset_index, 100_000_010);
1602 assert_eq!(yes.price_decimals, OUTCOME_PRICE_DECIMALS);
1603 assert_eq!(yes.size_decimals, OUTCOME_SIZE_DECIMALS);
1604 assert_eq!(yes.tick_size, dec!(0.0001));
1605 assert_eq!(yes.lot_size, dec!(0.01));
1606 assert_eq!(yes.quote.as_str(), "USDH");
1607 assert!(yes.active);
1608
1609 let yes_meta = yes.outcome.as_ref().unwrap();
1610 assert_eq!(yes_meta.outcome_index, 1);
1611 assert_eq!(yes_meta.outcome_side, 0);
1612 assert_eq!(yes_meta.market_name.as_str(), "BTC daily");
1613 assert_eq!(yes_meta.side_name.unwrap().as_str(), "Yes");
1614 assert_eq!(
1615 yes_meta.description.unwrap().as_str(),
1616 "BTC settles above strike at 06:00 UTC"
1617 );
1618
1619 let no = &defs[1];
1620 assert_eq!(no.symbol.as_str(), "1-NO-OUTCOME");
1621 assert_eq!(no.raw_symbol.as_str(), "#11");
1622 assert_eq!(no.asset_index, 100_000_011);
1623 let no_meta = no.outcome.as_ref().unwrap();
1624 assert_eq!(no_meta.outcome_side, 1);
1625 assert_eq!(no_meta.side_name.unwrap().as_str(), "No");
1626 }
1627
1628 #[rstest]
1629 fn test_parse_outcome_instruments_handles_missing_side_specs() {
1630 let meta = OutcomeMeta {
1631 outcomes: vec![OutcomeMarket {
1632 outcome: 5,
1633 name: "Recurring".to_string(),
1634 description: String::new(),
1635 side_specs: vec![],
1636 }],
1637 questions: vec![],
1638 };
1639
1640 let defs = parse_outcome_instruments(&meta).unwrap();
1641 assert_eq!(defs.len(), 2);
1642
1643 assert_eq!(
1647 defs[0]
1648 .outcome
1649 .as_ref()
1650 .unwrap()
1651 .side_name
1652 .unwrap()
1653 .as_str(),
1654 "Yes"
1655 );
1656 assert_eq!(
1657 defs[1]
1658 .outcome
1659 .as_ref()
1660 .unwrap()
1661 .side_name
1662 .unwrap()
1663 .as_str(),
1664 "No"
1665 );
1666
1667 for def in &defs {
1668 assert!(def.outcome.as_ref().unwrap().description.is_none());
1669 }
1670
1671 assert_eq!(defs[0].asset_index, 100_000_050);
1672 assert_eq!(defs[1].asset_index, 100_000_051);
1673 }
1674
1675 #[rstest]
1676 fn test_get_usdh_currency_registers_with_explicit_precision() {
1677 let currency = get_usdh_currency();
1678 assert_eq!(currency.code.as_str(), "USDH");
1679 assert_eq!(currency.precision, 8);
1680 assert_eq!(currency.currency_type, CurrencyType::Crypto);
1681
1682 let again = get_usdh_currency();
1684 assert_eq!(again, currency);
1685 assert!(Currency::try_from_str("USDH").is_some());
1686 }
1687
1688 #[rstest]
1689 fn test_create_instrument_from_def_outcome_emits_binary_option() {
1690 let meta = OutcomeMeta {
1691 outcomes: vec![OutcomeMarket {
1692 outcome: 2,
1693 name: "Recurring BTC".to_string(),
1694 description: "Daily settlement".to_string(),
1695 side_specs: vec![
1696 OutcomeSideSpec {
1697 name: "Yes".to_string(),
1698 },
1699 OutcomeSideSpec {
1700 name: "No".to_string(),
1701 },
1702 ],
1703 }],
1704 questions: vec![],
1705 };
1706
1707 let defs = parse_outcome_instruments(&meta).unwrap();
1708 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1709
1710 match instrument {
1711 InstrumentAny::BinaryOption(bo) => {
1712 assert_eq!(bo.id.symbol.as_str(), "2-YES-OUTCOME");
1713 assert_eq!(bo.raw_symbol.as_str(), "#20");
1714 assert_eq!(bo.asset_class, AssetClass::Alternative);
1715 assert_eq!(bo.currency.code.as_str(), "USDH");
1716 assert_eq!(bo.price_precision, OUTCOME_PRICE_DECIMALS as u8);
1717 assert_eq!(bo.size_precision, OUTCOME_SIZE_DECIMALS as u8);
1718 assert_eq!(bo.outcome.unwrap().as_str(), "Yes");
1719 assert_eq!(bo.description.unwrap().as_str(), "Daily settlement");
1720
1721 let info = bo.info.expect("info should be populated for outcomes");
1722 assert_eq!(info.get_u64("outcome_index"), Some(2));
1723 assert_eq!(info.get_u64("outcome_side"), Some(0));
1724 assert_eq!(info.get_u64("encoding"), Some(20));
1725 assert_eq!(info.get_u64("asset_id"), Some(100_000_020));
1726 assert_eq!(info.get_str("side_name"), Some("Yes"));
1727 assert_eq!(info.get_str("market_name"), Some("Recurring BTC"));
1728 }
1729 other => panic!("Expected BinaryOption, was {other:?}"),
1730 }
1731 }
1732
1733 #[rstest]
1734 fn test_create_instrument_from_def_outcome_info_carries_parsed_description() {
1735 let meta = OutcomeMeta {
1736 outcomes: vec![OutcomeMarket {
1737 outcome: 5,
1738 name: "Recurring BTC".to_string(),
1739 description:
1740 "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
1741 .to_string(),
1742 side_specs: vec![
1743 OutcomeSideSpec {
1744 name: "Yes".to_string(),
1745 },
1746 OutcomeSideSpec {
1747 name: "No".to_string(),
1748 },
1749 ],
1750 }],
1751 questions: vec![],
1752 };
1753
1754 let defs = parse_outcome_instruments(&meta).unwrap();
1755 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1756
1757 match yes {
1758 InstrumentAny::BinaryOption(bo) => {
1759 let info = bo.info.expect("info should be populated for outcomes");
1760 assert_eq!(info.get_str("class"), Some("priceBinary"));
1761 assert_eq!(info.get_str("underlying"), Some("BTC"));
1762 assert_eq!(info.get_str("expiry"), Some("20260508-0600"));
1763 assert_eq!(info.get_str("target_price"), Some("81041"));
1764 assert_eq!(info.get_str("period"), Some("1d"));
1765 assert!(info.get("question").is_none());
1766 }
1767 other => panic!("Expected BinaryOption, was {other:?}"),
1768 }
1769 }
1770
1771 #[rstest]
1772 fn test_create_instrument_from_def_outcome_info_merges_parent_question() {
1773 let meta = OutcomeMeta {
1774 outcomes: vec![
1775 OutcomeMarket {
1776 outcome: 6,
1777 name: "Recurring Fallback".to_string(),
1778 description: "other".to_string(),
1779 side_specs: vec![],
1780 },
1781 OutcomeMarket {
1782 outcome: 7,
1783 name: "Recurring Named Outcome".to_string(),
1784 description: "index:0".to_string(),
1785 side_specs: vec![],
1786 },
1787 ],
1788 questions: vec![OutcomeQuestion {
1789 question: 0,
1790 name: "Recurring".to_string(),
1791 description:
1792 "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
1793 .to_string(),
1794 fallback_outcome: Some(6),
1795 named_outcomes: vec![7, 8, 9],
1796 settled_named_outcomes: vec![],
1797 }],
1798 };
1799
1800 let defs = parse_outcome_instruments(&meta).unwrap();
1801
1802 let named = create_instrument_from_def(&defs[2], UnixNanos::default()).unwrap();
1804 match named {
1805 InstrumentAny::BinaryOption(bo) => {
1806 assert_eq!(bo.id.symbol.as_str(), "7-YES-OUTCOME");
1807 let info = bo.info.expect("info should be populated for outcomes");
1808 assert_eq!(info.get_u64("named_index"), Some(0));
1809 assert_eq!(info.get_u64("question"), Some(0));
1810 assert_eq!(info.get_str("question_name"), Some("Recurring"));
1811 assert_eq!(info.get_str("question_class"), Some("priceBucket"));
1812 assert_eq!(info.get_str("question_underlying"), Some("BTC"));
1813 assert_eq!(
1814 info.get_str("question_price_thresholds"),
1815 Some("79303,82540"),
1816 );
1817 assert_eq!(info.get_str("question_expiry"), Some("20260508-0600"));
1818 }
1819 other => panic!("Expected BinaryOption, was {other:?}"),
1820 }
1821
1822 let fallback = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1824 match fallback {
1825 InstrumentAny::BinaryOption(bo) => {
1826 assert_eq!(bo.id.symbol.as_str(), "6-YES-OUTCOME");
1827 let info = bo.info.expect("info should be populated for outcomes");
1828 assert_eq!(info.get_bool("is_fallback"), Some(true));
1829 assert_eq!(info.get_u64("question"), Some(0));
1830 assert_eq!(info.get_str("question_class"), Some("priceBucket"));
1831 }
1832 other => panic!("Expected BinaryOption, was {other:?}"),
1833 }
1834 }
1835
1836 #[rstest]
1837 fn test_parse_fill_report_outcome_round_trip() {
1838 let meta = OutcomeMeta {
1839 outcomes: vec![OutcomeMarket {
1840 outcome: 42,
1841 name: "BTC daily".to_string(),
1842 description: "BTC settles above strike at 06:00 UTC".to_string(),
1843 side_specs: vec![
1844 OutcomeSideSpec {
1845 name: "Yes".to_string(),
1846 },
1847 OutcomeSideSpec {
1848 name: "No".to_string(),
1849 },
1850 ],
1851 }],
1852 questions: vec![],
1853 };
1854
1855 let defs = parse_outcome_instruments(&meta).unwrap();
1856 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1857 assert_eq!(yes.id().symbol.as_str(), "42-YES-OUTCOME");
1858
1859 let fill = HyperliquidFill {
1860 coin: Ustr::from("#420"),
1861 px: "0.5500".to_string(),
1862 sz: "1000.00".to_string(),
1863 side: HyperliquidSide::Buy,
1864 time: 1_704_470_400_000,
1865 start_position: "0.00".to_string(),
1866 dir: HyperliquidFillDirection::OpenLong,
1867 closed_pnl: "0.0".to_string(),
1868 hash: "0xfeed".to_string(),
1869 oid: 99_001,
1870 crossed: true,
1871 fee: "0.0".to_string(),
1872 fee_token: Ustr::from("+420"),
1873 };
1874
1875 let account_id = AccountId::from("HYPERLIQUID-001");
1876 let report = parse_fill_report(&fill, &yes, account_id, UnixNanos::default()).unwrap();
1877
1878 assert_eq!(report.commission.currency.code.as_str(), "USDH");
1882 assert!(report.commission.as_decimal().is_zero());
1883 assert_eq!(report.order_side, OrderSide::Buy);
1884 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1885 assert_eq!(report.last_qty.as_decimal(), dec!(1000));
1886 assert_eq!(report.last_px.as_decimal(), dec!(0.55));
1887 }
1888
1889 #[rstest]
1890 fn test_resolve_fee_currency_outcome_token_returns_quote_even_when_registered() {
1891 let meta = OutcomeMeta {
1892 outcomes: vec![OutcomeMarket {
1893 outcome: 88,
1894 name: "Edge".to_string(),
1895 description: String::new(),
1896 side_specs: vec![],
1897 }],
1898 questions: vec![],
1899 };
1900 let defs = parse_outcome_instruments(&meta).unwrap();
1901 let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1902
1903 let _ = get_currency("+880");
1906 assert!(Currency::try_from_str("+880").is_some());
1907
1908 let currency = resolve_fee_currency("+880", Decimal::ZERO, &yes)
1909 .expect("zero-fee outcome side token must resolve to quote currency");
1910 assert_eq!(currency.code.as_str(), "USDH");
1911
1912 let err = resolve_fee_currency("+880", dec!(0.01), &yes).unwrap_err();
1913 let err_msg = err.to_string();
1914 assert!(err_msg.contains("Outcome side token '+880'"));
1915 assert!(err_msg.contains("non-zero fee"));
1916 }
1917
1918 #[rstest]
1919 #[case("+50", true)]
1920 #[case("+0", true)]
1921 #[case("+880", true)]
1922 #[case("", false)]
1923 #[case("+", false)]
1924 #[case("+abc", false)]
1925 #[case("+50a", false)]
1926 #[case("#50", false)]
1927 #[case("USDC", false)]
1928 #[case("-50", false)]
1929 fn test_is_outcome_side_token(#[case] input: &str, #[case] expected: bool) {
1930 assert_eq!(is_outcome_side_token(input), expected);
1931 }
1932
1933 #[rstest]
1934 fn test_resolve_fee_currency_falls_back_to_quote_when_unregistered_and_zero_fee() {
1935 let meta = OutcomeMeta {
1936 outcomes: vec![OutcomeMarket {
1937 outcome: 77,
1938 name: "Edge".to_string(),
1939 description: String::new(),
1940 side_specs: vec![],
1941 }],
1942 questions: vec![],
1943 };
1944
1945 let defs = parse_outcome_instruments(&meta).unwrap();
1946 let no = create_instrument_from_def(&defs[1], UnixNanos::default()).unwrap();
1947
1948 let currency = resolve_fee_currency("+UNREGISTERED-TOKEN", Decimal::ZERO, &no)
1951 .expect("zero-fee fallback should succeed");
1952 assert_eq!(currency.code.as_str(), "USDH");
1953
1954 let err = resolve_fee_currency("+UNREGISTERED-TOKEN", dec!(0.01), &no).unwrap_err();
1955 assert!(err.to_string().contains("non-zero fee"));
1956 }
1957
1958 #[rstest]
1959 fn test_parse_outcome_expiry_ns_round_trip() {
1960 let ns = parse_outcome_expiry_ns("20260508-0600").unwrap();
1962 assert_eq!(ns.as_u64(), 1_778_220_000_000_000_000);
1963 }
1964
1965 #[rstest]
1966 #[case("")]
1967 #[case("20260508")]
1968 #[case("20260508-")]
1969 #[case("20260508-0600 ")]
1970 #[case("2026-05-08-06-00")]
1971 #[case("20261308-0600")]
1972 fn test_parse_outcome_expiry_ns_rejects_bad_input(#[case] input: &str) {
1973 assert!(parse_outcome_expiry_ns(input).is_none());
1974 }
1975
1976 #[rstest]
1977 fn test_parse_outcome_instruments_pulls_expiry_from_price_binary() {
1978 let meta = OutcomeMeta {
1979 outcomes: vec![OutcomeMarket {
1980 outcome: 5,
1981 name: "Recurring".to_string(),
1982 description:
1983 "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
1984 .to_string(),
1985 side_specs: vec![
1986 OutcomeSideSpec {
1987 name: "Yes".to_string(),
1988 },
1989 OutcomeSideSpec {
1990 name: "No".to_string(),
1991 },
1992 ],
1993 }],
1994 questions: vec![],
1995 };
1996
1997 let defs = parse_outcome_instruments(&meta).unwrap();
1998 let yes_meta = defs[0].outcome.as_ref().unwrap();
1999 assert_eq!(yes_meta.expiration_ns.as_u64(), 1_778_220_000_000_000_000);
2000 }
2001
2002 #[rstest]
2003 fn test_parse_outcome_instruments_inherits_expiry_from_parent_question() {
2004 let meta = OutcomeMeta {
2008 outcomes: vec![
2009 OutcomeMarket {
2010 outcome: 6,
2011 name: "Recurring Fallback".to_string(),
2012 description: "other".to_string(),
2013 side_specs: vec![],
2014 },
2015 OutcomeMarket {
2016 outcome: 7,
2017 name: "Recurring Named Outcome".to_string(),
2018 description: "index:0".to_string(),
2019 side_specs: vec![],
2020 },
2021 ],
2022 questions: vec![OutcomeQuestion {
2023 question: 0,
2024 name: "Recurring".to_string(),
2025 description:
2026 "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
2027 .to_string(),
2028 fallback_outcome: Some(6),
2029 named_outcomes: vec![7, 8, 9],
2030 settled_named_outcomes: vec![],
2031 }],
2032 };
2033
2034 let defs = parse_outcome_instruments(&meta).unwrap();
2035 let expected_ns: u64 = 1_778_220_000_000_000_000;
2036
2037 for def in &defs {
2038 let outcome = def.outcome.as_ref().unwrap();
2039 assert_eq!(
2040 outcome.expiration_ns.as_u64(),
2041 expected_ns,
2042 "outcome {} side {} should inherit expiry",
2043 outcome.outcome_index,
2044 outcome.outcome_side,
2045 );
2046 }
2047 }
2048
2049 #[rstest]
2050 fn test_derive_outcome_settlements_returns_empty_when_no_questions() {
2051 let meta = OutcomeMeta {
2052 outcomes: vec![],
2053 questions: vec![],
2054 };
2055 assert!(derive_outcome_settlements(&meta).is_empty());
2056 }
2057
2058 #[rstest]
2059 fn test_derive_outcome_settlements_returns_empty_when_no_questions_settled() {
2060 let meta = OutcomeMeta {
2061 outcomes: vec![],
2062 questions: vec![OutcomeQuestion {
2063 question: 0,
2064 name: "Recurring".to_string(),
2065 description: "class:priceBucket|expiry:20260508-0600".to_string(),
2066 fallback_outcome: Some(6),
2067 named_outcomes: vec![7, 8, 9],
2068 settled_named_outcomes: vec![],
2069 }],
2070 };
2071
2072 assert!(derive_outcome_settlements(&meta).is_empty());
2073 }
2074
2075 #[rstest]
2076 fn test_derive_outcome_settlements_marks_winners_losers_and_fallback() {
2077 let meta = OutcomeMeta {
2078 outcomes: vec![],
2079 questions: vec![OutcomeQuestion {
2080 question: 0,
2081 name: "Recurring".to_string(),
2082 description: "class:priceBucket|expiry:20260508-0600".to_string(),
2083 fallback_outcome: Some(6),
2084 named_outcomes: vec![7, 8, 9],
2085 settled_named_outcomes: vec![8],
2086 }],
2087 };
2088
2089 let settlements = derive_outcome_settlements(&meta);
2090 let lookup: ahash::AHashMap<(u32, u8), u8> = settlements
2091 .into_iter()
2092 .map(|s| ((s.outcome_index, s.outcome_side), s.final_value))
2093 .collect();
2094
2095 assert_eq!(lookup[&(8, 0)], 1);
2097 assert_eq!(lookup[&(8, 1)], 0);
2098
2099 for losing in [7, 9, 6] {
2101 assert_eq!(lookup[&(losing, 0)], 0, "outcome {losing} Yes side");
2102 assert_eq!(lookup[&(losing, 1)], 1, "outcome {losing} No side");
2103 }
2104
2105 assert_eq!(lookup.len(), 8);
2106 }
2107
2108 #[rstest]
2109 fn test_parse_outcome_meta_question_settlement_round_trip() {
2110 let json = r#"{
2111 "outcomes": [{"outcome": 5, "name": "Recurring", "description": "class:priceBinary|expiry:20260508-0600", "sideSpecs": []}],
2112 "questions": [{
2113 "question": 0,
2114 "name": "Recurring",
2115 "description": "class:priceBucket|expiry:20260508-0600",
2116 "fallbackOutcome": 6,
2117 "namedOutcomes": [7, 8, 9],
2118 "settledNamedOutcomes": [8]
2119 }]
2120 }"#;
2121
2122 let meta: OutcomeMeta = serde_json::from_str(json).unwrap();
2123 assert_eq!(meta.questions.len(), 1);
2124 let q = &meta.questions[0];
2125 assert_eq!(q.fallback_outcome, Some(6));
2126 assert_eq!(q.named_outcomes, vec![7, 8, 9]);
2127 assert_eq!(q.settled_named_outcomes, vec![8]);
2128
2129 assert!(meta.parent_question(7).is_some());
2130 assert!(meta.parent_question(6).is_some());
2131 assert!(meta.parent_question(99).is_none());
2132 }
2133}