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 => TimeInForce::Ioc,
272        HyperliquidTimeInForce::Alo => TimeInForce::Gtc, // ALO (post-only) maps to GTC
273    }
274}
275
276/// Determines the TP/SL type based on order type and side.
277///
278/// # Logic
279///
280/// For buy orders:
281/// - Stop orders (trigger below current price) -> Stop Loss
282/// - Take profit orders (trigger above current price) -> Take Profit
283///
284/// For sell orders:
285/// - Stop orders (trigger above current price) -> Stop Loss
286/// - Take profit orders (trigger below current price) -> Take Profit
287pub fn determine_tpsl_type(order_type: OrderType, is_buy: bool) -> HyperliquidTpSl {
288    match order_type {
289        OrderType::StopMarket
290        | OrderType::StopLimit
291        | OrderType::TrailingStopMarket
292        | OrderType::TrailingStopLimit => HyperliquidTpSl::Sl,
293        OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidTpSl::Tp,
294        _ => {
295            // Default logic based on side if order type is ambiguous
296            if is_buy {
297                HyperliquidTpSl::Sl
298            } else {
299                HyperliquidTpSl::Tp
300            }
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use rstest::rstest;
308
309    use super::*;
310
311    #[rstest]
312    fn test_outcome_asset_id_to_wire_symbols() {
313        let asset_id = HyperliquidAssetId::outcome(1, 0);
314
315        assert_eq!(outcome_asset_id_to_coin(asset_id).unwrap(), "#10");
316        assert_eq!(outcome_asset_id_to_token(asset_id).unwrap(), "+10");
317    }
318
319    #[rstest]
320    fn test_outcome_asset_id_to_wire_symbols_rejects_non_outcome() {
321        let err = outcome_asset_id_to_coin(HyperliquidAssetId::spot(7)).unwrap_err();
322        assert!(
323            err.to_string()
324                .contains("Invalid Hyperliquid outcome asset ID"),
325            "unexpected error: {err}",
326        );
327    }
328
329    #[rstest]
330    fn test_outcome_asset_id_instrument_id_roundtrip() {
331        let asset_id = HyperliquidAssetId::outcome(3, 1);
332        let instrument_id = outcome_asset_id_to_instrument_id(asset_id).unwrap();
333
334        assert_eq!(
335            instrument_id,
336            InstrumentId::from("3-NO-OUTCOME.HYPERLIQUID")
337        );
338        assert_eq!(
339            outcome_asset_id_from_instrument_id(instrument_id).unwrap(),
340            asset_id,
341        );
342    }
343
344    #[rstest]
345    fn test_outcome_asset_id_to_instrument_id_yes_side() {
346        let asset_id = HyperliquidAssetId::outcome(25, 0);
347        let instrument_id = outcome_asset_id_to_instrument_id(asset_id).unwrap();
348
349        assert_eq!(
350            instrument_id,
351            InstrumentId::from("25-YES-OUTCOME.HYPERLIQUID")
352        );
353    }
354
355    #[rstest]
356    #[case("#10.HYPERLIQUID", 1, 0)]
357    #[case("+10.HYPERLIQUID", 1, 0)]
358    #[case("1-YES-OUTCOME.HYPERLIQUID", 1, 0)]
359    #[case("1-NO-OUTCOME.HYPERLIQUID", 1, 1)]
360    fn test_outcome_asset_id_from_instrument_id_accepts_all_forms(
361        #[case] symbol: &str,
362        #[case] outcome_index: u32,
363        #[case] side: u8,
364    ) {
365        let instrument_id = InstrumentId::from(symbol);
366        let asset_id = outcome_asset_id_from_instrument_id(instrument_id).unwrap();
367
368        assert_eq!(asset_id, HyperliquidAssetId::outcome(outcome_index, side));
369    }
370
371    #[rstest]
372    fn test_nautilus_to_hyperliquid_limit_order() {
373        let result =
374            nautilus_order_type_to_hyperliquid(OrderType::Limit, Some(TimeInForce::Gtc), None)
375                .unwrap();
376
377        match result {
378            HyperliquidOrderType::Limit { tif } => {
379                assert_eq!(tif, HyperliquidTimeInForce::Gtc);
380            }
381            _ => panic!("Expected Limit order type"),
382        }
383    }
384
385    #[rstest]
386    fn test_nautilus_to_hyperliquid_stop_market() {
387        let result = nautilus_order_type_to_hyperliquid(
388            OrderType::StopMarket,
389            None,
390            Some(Decimal::new(49000, 0)),
391        )
392        .unwrap();
393
394        match result {
395            HyperliquidOrderType::Trigger {
396                is_market,
397                trigger_px,
398                tpsl,
399            } => {
400                assert!(is_market);
401                assert_eq!(trigger_px, "49000");
402                assert_eq!(tpsl, HyperliquidTpSl::Sl);
403            }
404            _ => panic!("Expected Trigger order type"),
405        }
406    }
407
408    #[rstest]
409    fn test_nautilus_to_hyperliquid_stop_limit() {
410        let result = nautilus_order_type_to_hyperliquid(
411            OrderType::StopLimit,
412            None,
413            Some(Decimal::new(49000, 0)),
414        )
415        .unwrap();
416
417        match result {
418            HyperliquidOrderType::Trigger {
419                is_market,
420                trigger_px,
421                tpsl,
422            } => {
423                assert!(!is_market);
424                assert_eq!(trigger_px, "49000");
425                assert_eq!(tpsl, HyperliquidTpSl::Sl);
426            }
427            _ => panic!("Expected Trigger order type"),
428        }
429    }
430
431    #[rstest]
432    fn test_nautilus_to_hyperliquid_take_profit_market() {
433        let result = nautilus_order_type_to_hyperliquid(
434            OrderType::MarketIfTouched,
435            None,
436            Some(Decimal::new(51000, 0)),
437        )
438        .unwrap();
439
440        match result {
441            HyperliquidOrderType::Trigger {
442                is_market,
443                trigger_px,
444                tpsl,
445            } => {
446                assert!(is_market);
447                assert_eq!(trigger_px, "51000");
448                assert_eq!(tpsl, HyperliquidTpSl::Tp);
449            }
450            _ => panic!("Expected Trigger order type"),
451        }
452    }
453
454    #[rstest]
455    fn test_nautilus_to_hyperliquid_take_profit_limit() {
456        let result = nautilus_order_type_to_hyperliquid(
457            OrderType::LimitIfTouched,
458            None,
459            Some(Decimal::new(51000, 0)),
460        )
461        .unwrap();
462
463        match result {
464            HyperliquidOrderType::Trigger {
465                is_market,
466                trigger_px,
467                tpsl,
468            } => {
469                assert!(!is_market);
470                assert_eq!(trigger_px, "51000");
471                assert_eq!(tpsl, HyperliquidTpSl::Tp);
472            }
473            _ => panic!("Expected Trigger order type"),
474        }
475    }
476
477    #[rstest]
478    fn test_hyperliquid_to_nautilus_limit() {
479        let hl_order = HyperliquidOrderType::Limit {
480            tif: HyperliquidTimeInForce::Gtc,
481        };
482        assert_eq!(
483            hyperliquid_order_type_to_nautilus(&hl_order),
484            OrderType::Limit
485        );
486    }
487
488    #[rstest]
489    fn test_hyperliquid_to_nautilus_stop_market() {
490        let hl_order = HyperliquidOrderType::Trigger {
491            is_market: true,
492            trigger_px: "49000".to_string(),
493            tpsl: HyperliquidTpSl::Sl,
494        };
495        assert_eq!(
496            hyperliquid_order_type_to_nautilus(&hl_order),
497            OrderType::StopMarket
498        );
499    }
500
501    #[rstest]
502    fn test_hyperliquid_to_nautilus_stop_limit() {
503        let hl_order = HyperliquidOrderType::Trigger {
504            is_market: false,
505            trigger_px: "49000".to_string(),
506            tpsl: HyperliquidTpSl::Sl,
507        };
508        assert_eq!(
509            hyperliquid_order_type_to_nautilus(&hl_order),
510            OrderType::StopLimit
511        );
512    }
513
514    #[rstest]
515    fn test_hyperliquid_to_nautilus_take_profit_market() {
516        let hl_order = HyperliquidOrderType::Trigger {
517            is_market: true,
518            trigger_px: "51000".to_string(),
519            tpsl: HyperliquidTpSl::Tp,
520        };
521        assert_eq!(
522            hyperliquid_order_type_to_nautilus(&hl_order),
523            OrderType::MarketIfTouched
524        );
525    }
526
527    #[rstest]
528    fn test_hyperliquid_to_nautilus_take_profit_limit() {
529        let hl_order = HyperliquidOrderType::Trigger {
530            is_market: false,
531            trigger_px: "51000".to_string(),
532            tpsl: HyperliquidTpSl::Tp,
533        };
534        assert_eq!(
535            hyperliquid_order_type_to_nautilus(&hl_order),
536            OrderType::LimitIfTouched
537        );
538    }
539
540    #[rstest]
541    fn test_time_in_force_conversions() {
542        // Test Nautilus to Hyperliquid
543        assert_eq!(
544            nautilus_time_in_force_to_hyperliquid(TimeInForce::Gtc).unwrap(),
545            HyperliquidTimeInForce::Gtc
546        );
547        assert_eq!(
548            nautilus_time_in_force_to_hyperliquid(TimeInForce::Ioc).unwrap(),
549            HyperliquidTimeInForce::Ioc
550        );
551
552        // Test Hyperliquid to Nautilus
553        assert_eq!(
554            hyperliquid_time_in_force_to_nautilus(HyperliquidTimeInForce::Gtc),
555            TimeInForce::Gtc
556        );
557        assert_eq!(
558            hyperliquid_time_in_force_to_nautilus(HyperliquidTimeInForce::Ioc),
559            TimeInForce::Ioc
560        );
561        assert_eq!(
562            hyperliquid_time_in_force_to_nautilus(HyperliquidTimeInForce::Alo),
563            TimeInForce::Gtc
564        );
565    }
566
567    #[rstest]
568    #[case(TimeInForce::Fok, "FOK")]
569    #[case(TimeInForce::Gtd, "GTD")]
570    #[case(TimeInForce::Day, "DAY")]
571    #[case(TimeInForce::AtTheOpen, "AT_THE_OPEN")]
572    #[case(TimeInForce::AtTheClose, "AT_THE_CLOSE")]
573    fn test_unsupported_time_in_force_returns_error(#[case] tif: TimeInForce, #[case] name: &str) {
574        let result = nautilus_time_in_force_to_hyperliquid(tif);
575        assert!(result.is_err());
576        assert!(
577            result
578                .unwrap_err()
579                .to_string()
580                .contains(&format!("{name} time in force is not supported"))
581        );
582    }
583
584    #[rstest]
585    fn test_conditional_order_type_conversions() {
586        // Test Hyperliquid conditional to Nautilus
587        assert_eq!(
588            hyperliquid_conditional_to_nautilus(HyperliquidConditionalOrderType::StopMarket),
589            OrderType::StopMarket
590        );
591        assert_eq!(
592            hyperliquid_conditional_to_nautilus(HyperliquidConditionalOrderType::StopLimit),
593            OrderType::StopLimit
594        );
595        assert_eq!(
596            hyperliquid_conditional_to_nautilus(HyperliquidConditionalOrderType::TakeProfitMarket),
597            OrderType::MarketIfTouched
598        );
599        assert_eq!(
600            hyperliquid_conditional_to_nautilus(HyperliquidConditionalOrderType::TakeProfitLimit),
601            OrderType::LimitIfTouched
602        );
603
604        // Test Nautilus to Hyperliquid conditional
605        assert_eq!(
606            nautilus_to_hyperliquid_conditional(OrderType::StopMarket),
607            HyperliquidConditionalOrderType::StopMarket
608        );
609        assert_eq!(
610            nautilus_to_hyperliquid_conditional(OrderType::StopLimit),
611            HyperliquidConditionalOrderType::StopLimit
612        );
613        assert_eq!(
614            nautilus_to_hyperliquid_conditional(OrderType::MarketIfTouched),
615            HyperliquidConditionalOrderType::TakeProfitMarket
616        );
617        assert_eq!(
618            nautilus_to_hyperliquid_conditional(OrderType::LimitIfTouched),
619            HyperliquidConditionalOrderType::TakeProfitLimit
620        );
621    }
622
623    #[rstest]
624    fn test_determine_tpsl_type() {
625        // Stop orders should always be SL
626        assert_eq!(
627            determine_tpsl_type(OrderType::StopMarket, true),
628            HyperliquidTpSl::Sl
629        );
630        assert_eq!(
631            determine_tpsl_type(OrderType::StopLimit, false),
632            HyperliquidTpSl::Sl
633        );
634
635        // Take profit orders should always be TP
636        assert_eq!(
637            determine_tpsl_type(OrderType::MarketIfTouched, true),
638            HyperliquidTpSl::Tp
639        );
640        assert_eq!(
641            determine_tpsl_type(OrderType::LimitIfTouched, false),
642            HyperliquidTpSl::Tp
643        );
644
645        // Trailing stops should be SL
646        assert_eq!(
647            determine_tpsl_type(OrderType::TrailingStopMarket, true),
648            HyperliquidTpSl::Sl
649        );
650        assert_eq!(
651            determine_tpsl_type(OrderType::TrailingStopLimit, false),
652            HyperliquidTpSl::Sl
653        );
654    }
655}