Skip to main content

nautilus_hyperliquid/common/
converters.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Order type conversion utilities for Hyperliquid adapter.
17//!
18//! This module provides conversion functions between Nautilus core order types
19//! and Hyperliquid-specific order type representations.
20
21use 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
38/// Converts an outcome (HIP-4) asset ID to its spot coin representation.
39///
40/// # Errors
41///
42/// Returns an error if `asset_id` is not a valid outcome asset ID.
43pub 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
48/// Converts an outcome (HIP-4) asset ID to its token name representation.
49///
50/// # Errors
51///
52/// Returns an error if `asset_id` is not a valid outcome asset ID.
53pub 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
58/// Converts an outcome (HIP-4) asset ID to its canonical Nautilus instrument ID.
59///
60/// The instrument ID uses the form `{outcome_index}-{YES|NO}-OUTCOME.HYPERLIQUID`,
61/// symmetric with `-PERP` / `-SPOT`, so the human reading the ID can see which
62/// question and side they're trading. The venue wire forms (`#<encoding>` /
63/// `+<encoding>`) are preserved on the instrument's `raw_symbol` and base
64/// alias, not on the Nautilus symbol.
65///
66/// # Errors
67///
68/// Returns an error if `asset_id` is not a valid outcome asset ID.
69pub 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
79/// Parses an outcome (HIP-4) asset ID from a Nautilus instrument ID.
80///
81/// Accepts the Nautilus symbol form (`{N}-{YES|NO}-OUTCOME.HYPERLIQUID`) and,
82/// for compatibility with venue-wire-derived ids, also the
83/// `#<encoding>.HYPERLIQUID` and `+<encoding>.HYPERLIQUID` forms.
84///
85/// # Errors
86///
87/// Returns an error if the symbol matches none of the supported forms.
88pub 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
106/// Converts a Nautilus `OrderType` to a Hyperliquid order type configuration.
107///
108/// # Errors
109///
110/// Returns an error if the order type is unsupported, a required trigger price
111/// is missing, or the time in force is not supported.
112pub 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        // Regular limit order
119        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        // Stop market order (stop loss)
128        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        // Stop limit order (stop loss with limit)
140        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        // Market if touched (take profit market)
152        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        // Limit if touched (take profit limit)
164        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        // Trailing stop market (requires special handling)
176        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        // Trailing stop limit (requires special handling)
188        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
205/// Converts a Hyperliquid order type to a Nautilus `OrderType`.
206pub 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
220/// Converts a Hyperliquid conditional order type to a Nautilus `OrderType`.
221pub fn hyperliquid_conditional_to_nautilus(
222    conditional_type: HyperliquidConditionalOrderType,
223) -> OrderType {
224    OrderType::from(conditional_type)
225}
226
227/// Converts a Nautilus `OrderType` to a Hyperliquid conditional order type.
228///
229/// # Panics
230///
231/// Panics if the order type is not a conditional order type.
232pub fn nautilus_to_hyperliquid_conditional(
233    order_type: OrderType,
234) -> HyperliquidConditionalOrderType {
235    HyperliquidConditionalOrderType::from(order_type)
236}
237
238/// Converts a Nautilus `TimeInForce` to a Hyperliquid time in force.
239///
240/// # Errors
241///
242/// Returns an error if the time in force is not supported (e.g. FOK).
243pub 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
267/// Converts a Hyperliquid time in force to a Nautilus `TimeInForce`.
268pub 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
278/// Determines the TP/SL type based on order type and side.
279///
280/// # Logic
281///
282/// For buy orders:
283/// - Stop orders (trigger below current price) -> Stop Loss
284/// - Take profit orders (trigger above current price) -> Take Profit
285///
286/// For sell orders:
287/// - Stop orders (trigger above current price) -> Stop Loss
288/// - Take profit orders (trigger below current price) -> Take Profit
289pub 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            // Default logic based on side if order type is ambiguous
298            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        // Test Nautilus to Hyperliquid
545        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        // Test Hyperliquid to Nautilus
555        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        // Test Hyperliquid conditional to Nautilus
597        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        // Test Nautilus to Hyperliquid conditional
615        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        // Stop orders should always be SL
636        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        // Take profit orders should always be TP
646        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        // Trailing stops should be SL
656        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}