1use anyhow::Context;
22use nautilus_model::{
23 enums::{OrderType, TimeInForce},
24 identifiers::{InstrumentId, Symbol},
25};
26use rust_decimal::Decimal;
27
28use super::{
29 consts::HYPERLIQUID_VENUE,
30 enums::{
31 HyperliquidConditionalOrderType, HyperliquidOrderType, HyperliquidTimeInForce,
32 HyperliquidTpSl,
33 },
34 parse::{format_outcome_nautilus_symbol, parse_outcome_nautilus_symbol, parse_outcome_symbol},
35 types::HyperliquidAssetId,
36};
37
38pub fn outcome_asset_id_to_coin(asset_id: HyperliquidAssetId) -> anyhow::Result<String> {
44 let encoding = outcome_encoding(asset_id)?;
45 Ok(format!("#{encoding}"))
46}
47
48pub fn outcome_asset_id_to_token(asset_id: HyperliquidAssetId) -> anyhow::Result<String> {
54 let encoding = outcome_encoding(asset_id)?;
55 Ok(format!("+{encoding}"))
56}
57
58pub fn outcome_asset_id_to_instrument_id(
70 asset_id: HyperliquidAssetId,
71) -> anyhow::Result<InstrumentId> {
72 let encoding = outcome_encoding(asset_id)?;
73 let outcome_index = encoding / 10;
74 let side = u8::try_from(encoding % 10).unwrap_or(0);
75 let symbol = format_outcome_nautilus_symbol(outcome_index, side);
76 Ok(InstrumentId::new(Symbol::new(symbol), *HYPERLIQUID_VENUE))
77}
78
79pub fn outcome_asset_id_from_instrument_id(
89 instrument_id: InstrumentId,
90) -> anyhow::Result<HyperliquidAssetId> {
91 let symbol = instrument_id.symbol.as_str();
92
93 if let Some((outcome_index, side)) = parse_outcome_nautilus_symbol(symbol) {
94 return Ok(HyperliquidAssetId::outcome(outcome_index, side));
95 }
96
97 parse_outcome_symbol(symbol)
98}
99
100fn outcome_encoding(asset_id: HyperliquidAssetId) -> anyhow::Result<u32> {
101 asset_id
102 .outcome_encoding()
103 .with_context(|| format!("Invalid Hyperliquid outcome asset ID: {asset_id}"))
104}
105
106pub fn nautilus_order_type_to_hyperliquid(
113 order_type: OrderType,
114 time_in_force: Option<TimeInForce>,
115 trigger_price: Option<Decimal>,
116) -> anyhow::Result<HyperliquidOrderType> {
117 let result = match order_type {
118 OrderType::Limit => {
120 let tif = match time_in_force {
121 Some(t) => nautilus_time_in_force_to_hyperliquid(t)?,
122 None => HyperliquidTimeInForce::Gtc,
123 };
124 HyperliquidOrderType::Limit { tif }
125 }
126
127 OrderType::StopMarket => {
129 let trigger_px = trigger_price
130 .context("Trigger price required for StopMarket order")?
131 .to_string();
132 HyperliquidOrderType::Trigger {
133 is_market: true,
134 trigger_px,
135 tpsl: HyperliquidTpSl::Sl,
136 }
137 }
138
139 OrderType::StopLimit => {
141 let trigger_px = trigger_price
142 .context("Trigger price required for StopLimit order")?
143 .to_string();
144 HyperliquidOrderType::Trigger {
145 is_market: false,
146 trigger_px,
147 tpsl: HyperliquidTpSl::Sl,
148 }
149 }
150
151 OrderType::MarketIfTouched => {
153 let trigger_px = trigger_price
154 .context("Trigger price required for MarketIfTouched order")?
155 .to_string();
156 HyperliquidOrderType::Trigger {
157 is_market: true,
158 trigger_px,
159 tpsl: HyperliquidTpSl::Tp,
160 }
161 }
162
163 OrderType::LimitIfTouched => {
165 let trigger_px = trigger_price
166 .context("Trigger price required for LimitIfTouched order")?
167 .to_string();
168 HyperliquidOrderType::Trigger {
169 is_market: false,
170 trigger_px,
171 tpsl: HyperliquidTpSl::Tp,
172 }
173 }
174
175 OrderType::TrailingStopMarket => {
177 let trigger_px = trigger_price
178 .context("Trigger price required for TrailingStopMarket order")?
179 .to_string();
180 HyperliquidOrderType::Trigger {
181 is_market: true,
182 trigger_px,
183 tpsl: HyperliquidTpSl::Sl,
184 }
185 }
186
187 OrderType::TrailingStopLimit => {
189 let trigger_px = trigger_price
190 .context("Trigger price required for TrailingStopLimit order")?
191 .to_string();
192 HyperliquidOrderType::Trigger {
193 is_market: false,
194 trigger_px,
195 tpsl: HyperliquidTpSl::Sl,
196 }
197 }
198
199 _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
200 };
201
202 Ok(result)
203}
204
205pub fn hyperliquid_order_type_to_nautilus(hl_order_type: &HyperliquidOrderType) -> OrderType {
207 match hl_order_type {
208 HyperliquidOrderType::Limit { .. } => OrderType::Limit,
209 HyperliquidOrderType::Trigger {
210 is_market, tpsl, ..
211 } => match (is_market, tpsl) {
212 (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
213 (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
214 (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
215 (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
216 },
217 }
218}
219
220pub fn hyperliquid_conditional_to_nautilus(
222 conditional_type: HyperliquidConditionalOrderType,
223) -> OrderType {
224 OrderType::from(conditional_type)
225}
226
227pub fn nautilus_to_hyperliquid_conditional(
233 order_type: OrderType,
234) -> HyperliquidConditionalOrderType {
235 HyperliquidConditionalOrderType::from(order_type)
236}
237
238pub fn nautilus_time_in_force_to_hyperliquid(
244 tif: TimeInForce,
245) -> anyhow::Result<HyperliquidTimeInForce> {
246 match tif {
247 TimeInForce::Gtc => Ok(HyperliquidTimeInForce::Gtc),
248 TimeInForce::Ioc => Ok(HyperliquidTimeInForce::Ioc),
249 TimeInForce::Fok => {
250 anyhow::bail!("FOK time in force is not supported by Hyperliquid")
251 }
252 TimeInForce::Gtd => {
253 anyhow::bail!("GTD time in force is not supported by Hyperliquid")
254 }
255 TimeInForce::Day => {
256 anyhow::bail!("DAY time in force is not supported by Hyperliquid")
257 }
258 TimeInForce::AtTheOpen => {
259 anyhow::bail!("AT_THE_OPEN time in force is not supported by Hyperliquid")
260 }
261 TimeInForce::AtTheClose => {
262 anyhow::bail!("AT_THE_CLOSE time in force is not supported by Hyperliquid")
263 }
264 }
265}
266
267pub fn hyperliquid_time_in_force_to_nautilus(hl_tif: HyperliquidTimeInForce) -> TimeInForce {
269 match hl_tif {
270 HyperliquidTimeInForce::Gtc => TimeInForce::Gtc,
271 HyperliquidTimeInForce::Ioc
272 | HyperliquidTimeInForce::FrontendMarket
273 | HyperliquidTimeInForce::LiquidationMarket => TimeInForce::Ioc,
274 HyperliquidTimeInForce::Alo => TimeInForce::Gtc,
275 }
276}
277
278pub fn determine_tpsl_type(order_type: OrderType, is_buy: bool) -> HyperliquidTpSl {
290 match order_type {
291 OrderType::StopMarket
292 | OrderType::StopLimit
293 | OrderType::TrailingStopMarket
294 | OrderType::TrailingStopLimit => HyperliquidTpSl::Sl,
295 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidTpSl::Tp,
296 _ => {
297 if is_buy {
299 HyperliquidTpSl::Sl
300 } else {
301 HyperliquidTpSl::Tp
302 }
303 }
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use rstest::rstest;
310
311 use super::*;
312
313 #[rstest]
314 fn test_outcome_asset_id_to_wire_symbols() {
315 let asset_id = HyperliquidAssetId::outcome(1, 0);
316
317 assert_eq!(outcome_asset_id_to_coin(asset_id).unwrap(), "#10");
318 assert_eq!(outcome_asset_id_to_token(asset_id).unwrap(), "+10");
319 }
320
321 #[rstest]
322 fn test_outcome_asset_id_to_wire_symbols_rejects_non_outcome() {
323 let err = outcome_asset_id_to_coin(HyperliquidAssetId::spot(7)).unwrap_err();
324 assert!(
325 err.to_string()
326 .contains("Invalid Hyperliquid outcome asset ID"),
327 "unexpected error: {err}",
328 );
329 }
330
331 #[rstest]
332 fn test_outcome_asset_id_instrument_id_roundtrip() {
333 let asset_id = HyperliquidAssetId::outcome(3, 1);
334 let instrument_id = outcome_asset_id_to_instrument_id(asset_id).unwrap();
335
336 assert_eq!(
337 instrument_id,
338 InstrumentId::from("3-NO-OUTCOME.HYPERLIQUID")
339 );
340 assert_eq!(
341 outcome_asset_id_from_instrument_id(instrument_id).unwrap(),
342 asset_id,
343 );
344 }
345
346 #[rstest]
347 fn test_outcome_asset_id_to_instrument_id_yes_side() {
348 let asset_id = HyperliquidAssetId::outcome(25, 0);
349 let instrument_id = outcome_asset_id_to_instrument_id(asset_id).unwrap();
350
351 assert_eq!(
352 instrument_id,
353 InstrumentId::from("25-YES-OUTCOME.HYPERLIQUID")
354 );
355 }
356
357 #[rstest]
358 #[case("#10.HYPERLIQUID", 1, 0)]
359 #[case("+10.HYPERLIQUID", 1, 0)]
360 #[case("1-YES-OUTCOME.HYPERLIQUID", 1, 0)]
361 #[case("1-NO-OUTCOME.HYPERLIQUID", 1, 1)]
362 fn test_outcome_asset_id_from_instrument_id_accepts_all_forms(
363 #[case] symbol: &str,
364 #[case] outcome_index: u32,
365 #[case] side: u8,
366 ) {
367 let instrument_id = InstrumentId::from(symbol);
368 let asset_id = outcome_asset_id_from_instrument_id(instrument_id).unwrap();
369
370 assert_eq!(asset_id, HyperliquidAssetId::outcome(outcome_index, side));
371 }
372
373 #[rstest]
374 fn test_nautilus_to_hyperliquid_limit_order() {
375 let result =
376 nautilus_order_type_to_hyperliquid(OrderType::Limit, Some(TimeInForce::Gtc), None)
377 .unwrap();
378
379 match result {
380 HyperliquidOrderType::Limit { tif } => {
381 assert_eq!(tif, HyperliquidTimeInForce::Gtc);
382 }
383 _ => panic!("Expected Limit order type"),
384 }
385 }
386
387 #[rstest]
388 fn test_nautilus_to_hyperliquid_stop_market() {
389 let result = nautilus_order_type_to_hyperliquid(
390 OrderType::StopMarket,
391 None,
392 Some(Decimal::new(49000, 0)),
393 )
394 .unwrap();
395
396 match result {
397 HyperliquidOrderType::Trigger {
398 is_market,
399 trigger_px,
400 tpsl,
401 } => {
402 assert!(is_market);
403 assert_eq!(trigger_px, "49000");
404 assert_eq!(tpsl, HyperliquidTpSl::Sl);
405 }
406 _ => panic!("Expected Trigger order type"),
407 }
408 }
409
410 #[rstest]
411 fn test_nautilus_to_hyperliquid_stop_limit() {
412 let result = nautilus_order_type_to_hyperliquid(
413 OrderType::StopLimit,
414 None,
415 Some(Decimal::new(49000, 0)),
416 )
417 .unwrap();
418
419 match result {
420 HyperliquidOrderType::Trigger {
421 is_market,
422 trigger_px,
423 tpsl,
424 } => {
425 assert!(!is_market);
426 assert_eq!(trigger_px, "49000");
427 assert_eq!(tpsl, HyperliquidTpSl::Sl);
428 }
429 _ => panic!("Expected Trigger order type"),
430 }
431 }
432
433 #[rstest]
434 fn test_nautilus_to_hyperliquid_take_profit_market() {
435 let result = nautilus_order_type_to_hyperliquid(
436 OrderType::MarketIfTouched,
437 None,
438 Some(Decimal::new(51000, 0)),
439 )
440 .unwrap();
441
442 match result {
443 HyperliquidOrderType::Trigger {
444 is_market,
445 trigger_px,
446 tpsl,
447 } => {
448 assert!(is_market);
449 assert_eq!(trigger_px, "51000");
450 assert_eq!(tpsl, HyperliquidTpSl::Tp);
451 }
452 _ => panic!("Expected Trigger order type"),
453 }
454 }
455
456 #[rstest]
457 fn test_nautilus_to_hyperliquid_take_profit_limit() {
458 let result = nautilus_order_type_to_hyperliquid(
459 OrderType::LimitIfTouched,
460 None,
461 Some(Decimal::new(51000, 0)),
462 )
463 .unwrap();
464
465 match result {
466 HyperliquidOrderType::Trigger {
467 is_market,
468 trigger_px,
469 tpsl,
470 } => {
471 assert!(!is_market);
472 assert_eq!(trigger_px, "51000");
473 assert_eq!(tpsl, HyperliquidTpSl::Tp);
474 }
475 _ => panic!("Expected Trigger order type"),
476 }
477 }
478
479 #[rstest]
480 fn test_hyperliquid_to_nautilus_limit() {
481 let hl_order = HyperliquidOrderType::Limit {
482 tif: HyperliquidTimeInForce::Gtc,
483 };
484 assert_eq!(
485 hyperliquid_order_type_to_nautilus(&hl_order),
486 OrderType::Limit
487 );
488 }
489
490 #[rstest]
491 fn test_hyperliquid_to_nautilus_stop_market() {
492 let hl_order = HyperliquidOrderType::Trigger {
493 is_market: true,
494 trigger_px: "49000".to_string(),
495 tpsl: HyperliquidTpSl::Sl,
496 };
497 assert_eq!(
498 hyperliquid_order_type_to_nautilus(&hl_order),
499 OrderType::StopMarket
500 );
501 }
502
503 #[rstest]
504 fn test_hyperliquid_to_nautilus_stop_limit() {
505 let hl_order = HyperliquidOrderType::Trigger {
506 is_market: false,
507 trigger_px: "49000".to_string(),
508 tpsl: HyperliquidTpSl::Sl,
509 };
510 assert_eq!(
511 hyperliquid_order_type_to_nautilus(&hl_order),
512 OrderType::StopLimit
513 );
514 }
515
516 #[rstest]
517 fn test_hyperliquid_to_nautilus_take_profit_market() {
518 let hl_order = HyperliquidOrderType::Trigger {
519 is_market: true,
520 trigger_px: "51000".to_string(),
521 tpsl: HyperliquidTpSl::Tp,
522 };
523 assert_eq!(
524 hyperliquid_order_type_to_nautilus(&hl_order),
525 OrderType::MarketIfTouched
526 );
527 }
528
529 #[rstest]
530 fn test_hyperliquid_to_nautilus_take_profit_limit() {
531 let hl_order = HyperliquidOrderType::Trigger {
532 is_market: false,
533 trigger_px: "51000".to_string(),
534 tpsl: HyperliquidTpSl::Tp,
535 };
536 assert_eq!(
537 hyperliquid_order_type_to_nautilus(&hl_order),
538 OrderType::LimitIfTouched
539 );
540 }
541
542 #[rstest]
543 fn test_time_in_force_conversions() {
544 assert_eq!(
546 nautilus_time_in_force_to_hyperliquid(TimeInForce::Gtc).unwrap(),
547 HyperliquidTimeInForce::Gtc
548 );
549 assert_eq!(
550 nautilus_time_in_force_to_hyperliquid(TimeInForce::Ioc).unwrap(),
551 HyperliquidTimeInForce::Ioc
552 );
553
554 assert_eq!(
556 hyperliquid_time_in_force_to_nautilus(HyperliquidTimeInForce::Gtc),
557 TimeInForce::Gtc
558 );
559 assert_eq!(
560 hyperliquid_time_in_force_to_nautilus(HyperliquidTimeInForce::Ioc),
561 TimeInForce::Ioc
562 );
563 assert_eq!(
564 hyperliquid_time_in_force_to_nautilus(HyperliquidTimeInForce::Alo),
565 TimeInForce::Gtc
566 );
567 assert_eq!(
568 hyperliquid_time_in_force_to_nautilus(HyperliquidTimeInForce::FrontendMarket),
569 TimeInForce::Ioc
570 );
571 assert_eq!(
572 hyperliquid_time_in_force_to_nautilus(HyperliquidTimeInForce::LiquidationMarket),
573 TimeInForce::Ioc
574 );
575 }
576
577 #[rstest]
578 #[case(TimeInForce::Fok, "FOK")]
579 #[case(TimeInForce::Gtd, "GTD")]
580 #[case(TimeInForce::Day, "DAY")]
581 #[case(TimeInForce::AtTheOpen, "AT_THE_OPEN")]
582 #[case(TimeInForce::AtTheClose, "AT_THE_CLOSE")]
583 fn test_unsupported_time_in_force_returns_error(#[case] tif: TimeInForce, #[case] name: &str) {
584 let result = nautilus_time_in_force_to_hyperliquid(tif);
585 assert!(result.is_err());
586 assert!(
587 result
588 .unwrap_err()
589 .to_string()
590 .contains(&format!("{name} time in force is not supported"))
591 );
592 }
593
594 #[rstest]
595 fn test_conditional_order_type_conversions() {
596 assert_eq!(
598 hyperliquid_conditional_to_nautilus(HyperliquidConditionalOrderType::StopMarket),
599 OrderType::StopMarket
600 );
601 assert_eq!(
602 hyperliquid_conditional_to_nautilus(HyperliquidConditionalOrderType::StopLimit),
603 OrderType::StopLimit
604 );
605 assert_eq!(
606 hyperliquid_conditional_to_nautilus(HyperliquidConditionalOrderType::TakeProfitMarket),
607 OrderType::MarketIfTouched
608 );
609 assert_eq!(
610 hyperliquid_conditional_to_nautilus(HyperliquidConditionalOrderType::TakeProfitLimit),
611 OrderType::LimitIfTouched
612 );
613
614 assert_eq!(
616 nautilus_to_hyperliquid_conditional(OrderType::StopMarket),
617 HyperliquidConditionalOrderType::StopMarket
618 );
619 assert_eq!(
620 nautilus_to_hyperliquid_conditional(OrderType::StopLimit),
621 HyperliquidConditionalOrderType::StopLimit
622 );
623 assert_eq!(
624 nautilus_to_hyperliquid_conditional(OrderType::MarketIfTouched),
625 HyperliquidConditionalOrderType::TakeProfitMarket
626 );
627 assert_eq!(
628 nautilus_to_hyperliquid_conditional(OrderType::LimitIfTouched),
629 HyperliquidConditionalOrderType::TakeProfitLimit
630 );
631 }
632
633 #[rstest]
634 fn test_determine_tpsl_type() {
635 assert_eq!(
637 determine_tpsl_type(OrderType::StopMarket, true),
638 HyperliquidTpSl::Sl
639 );
640 assert_eq!(
641 determine_tpsl_type(OrderType::StopLimit, false),
642 HyperliquidTpSl::Sl
643 );
644
645 assert_eq!(
647 determine_tpsl_type(OrderType::MarketIfTouched, true),
648 HyperliquidTpSl::Tp
649 );
650 assert_eq!(
651 determine_tpsl_type(OrderType::LimitIfTouched, false),
652 HyperliquidTpSl::Tp
653 );
654
655 assert_eq!(
657 determine_tpsl_type(OrderType::TrailingStopMarket, true),
658 HyperliquidTpSl::Sl
659 );
660 assert_eq!(
661 determine_tpsl_type(OrderType::TrailingStopLimit, false),
662 HyperliquidTpSl::Sl
663 );
664 }
665}