nautilus_hyperliquid/common/
enums.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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
16use nautilus_model::enums::{AggressorSide, OrderSide};
17use serde::{Deserialize, Serialize};
18use strum::{AsRefStr, Display, EnumIter, EnumString};
19
20/// Represents the order side (Buy or Sell).
21///
22/// Hyperliquid uses "B" for Buy and "A" for Sell in API responses.
23#[derive(
24    Copy,
25    Clone,
26    Debug,
27    Display,
28    PartialEq,
29    Eq,
30    Hash,
31    AsRefStr,
32    EnumIter,
33    EnumString,
34    Serialize,
35    Deserialize,
36)]
37#[serde(rename_all = "UPPERCASE")]
38#[strum(serialize_all = "UPPERCASE")]
39pub enum HyperliquidSide {
40    #[serde(rename = "B")]
41    Buy,
42    #[serde(rename = "A")]
43    Sell,
44}
45
46impl From<OrderSide> for HyperliquidSide {
47    fn from(value: OrderSide) -> Self {
48        match value {
49            OrderSide::Buy => Self::Buy,
50            OrderSide::Sell => Self::Sell,
51            _ => panic!("Invalid `OrderSide`: {value:?}"),
52        }
53    }
54}
55
56impl From<HyperliquidSide> for OrderSide {
57    fn from(value: HyperliquidSide) -> Self {
58        match value {
59            HyperliquidSide::Buy => Self::Buy,
60            HyperliquidSide::Sell => Self::Sell,
61        }
62    }
63}
64
65impl From<HyperliquidSide> for AggressorSide {
66    fn from(value: HyperliquidSide) -> Self {
67        match value {
68            HyperliquidSide::Buy => Self::Buyer,
69            HyperliquidSide::Sell => Self::Seller,
70        }
71    }
72}
73
74/// Represents the time in force for limit orders.
75#[derive(
76    Copy,
77    Clone,
78    Debug,
79    Display,
80    PartialEq,
81    Eq,
82    Hash,
83    AsRefStr,
84    EnumIter,
85    EnumString,
86    Serialize,
87    Deserialize,
88)]
89#[serde(rename_all = "PascalCase")]
90#[strum(serialize_all = "PascalCase")]
91pub enum HyperliquidTimeInForce {
92    /// Add Liquidity Only - post-only order.
93    Alo,
94    /// Immediate or Cancel - fill immediately or cancel.
95    Ioc,
96    /// Good Till Cancel - remain on book until filled or cancelled.
97    Gtc,
98}
99
100/// Represents the order type configuration.
101#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
102#[serde(tag = "type", rename_all = "lowercase")]
103pub enum HyperliquidOrderType {
104    /// Limit order with time-in-force.
105    #[serde(rename = "limit")]
106    Limit { tif: HyperliquidTimeInForce },
107
108    /// Trigger order (stop or take profit).
109    #[serde(rename = "trigger")]
110    Trigger {
111        #[serde(rename = "isMarket")]
112        is_market: bool,
113        #[serde(rename = "triggerPx")]
114        trigger_px: String,
115        tpsl: HyperliquidTpSl,
116    },
117}
118
119/// Represents the take profit / stop loss type.
120#[derive(
121    Copy,
122    Clone,
123    Debug,
124    Display,
125    PartialEq,
126    Eq,
127    Hash,
128    AsRefStr,
129    EnumIter,
130    EnumString,
131    Serialize,
132    Deserialize,
133)]
134#[serde(rename_all = "lowercase")]
135#[strum(serialize_all = "lowercase")]
136pub enum HyperliquidTpSl {
137    /// Take Profit.
138    Tp,
139    /// Stop Loss.
140    Sl,
141}
142
143/// Represents the reduce only flag wrapper.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
145#[serde(transparent)]
146pub struct HyperliquidReduceOnly(pub bool);
147
148impl HyperliquidReduceOnly {
149    /// Creates a new reduce only flag.
150    pub fn new(reduce_only: bool) -> Self {
151        Self(reduce_only)
152    }
153
154    /// Returns whether this is a reduce only order.
155    pub fn is_reduce_only(&self) -> bool {
156        self.0
157    }
158}
159
160/// Represents the liquidity flag indicating maker or taker.
161#[derive(
162    Copy,
163    Clone,
164    Debug,
165    Display,
166    PartialEq,
167    Eq,
168    Hash,
169    AsRefStr,
170    EnumIter,
171    EnumString,
172    Serialize,
173    Deserialize,
174)]
175#[serde(rename_all = "lowercase")]
176#[strum(serialize_all = "lowercase")]
177pub enum HyperliquidLiquidityFlag {
178    Maker,
179    Taker,
180}
181
182impl From<bool> for HyperliquidLiquidityFlag {
183    /// Converts from `crossed` field in fill responses.
184    ///
185    /// `true` (crossed) -> Taker, `false` -> Maker
186    fn from(crossed: bool) -> Self {
187        if crossed {
188            HyperliquidLiquidityFlag::Taker
189        } else {
190            HyperliquidLiquidityFlag::Maker
191        }
192    }
193}
194
195#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
196#[serde(untagged)]
197pub enum HyperliquidRejectCode {
198    /// Price must be divisible by tick size.
199    Tick,
200    /// Order must have minimum value of $10.
201    MinTradeNtl,
202    /// Order must have minimum value of 10 {quote_token}.
203    MinTradeSpotNtl,
204    /// Insufficient margin to place order.
205    PerpMargin,
206    /// Reduce only order would increase position.
207    ReduceOnly,
208    /// Post only order would have immediately matched.
209    BadAloPx,
210    /// Order could not immediately match.
211    IocCancel,
212    /// Invalid TP/SL price.
213    BadTriggerPx,
214    /// No liquidity available for market order.
215    MarketOrderNoLiquidity,
216    /// Position increase at open interest cap.
217    PositionIncreaseAtOpenInterestCap,
218    /// Position flip at open interest cap.
219    PositionFlipAtOpenInterestCap,
220    /// Too aggressive at open interest cap.
221    TooAggressiveAtOpenInterestCap,
222    /// Open interest increase.
223    OpenInterestIncrease,
224    /// Insufficient spot balance.
225    InsufficientSpotBalance,
226    /// Oracle issue.
227    Oracle,
228    /// Perp max position.
229    PerpMaxPosition,
230    /// Missing order.
231    MissingOrder,
232    /// Unknown reject reason with raw error message.
233    Unknown(String),
234}
235
236impl HyperliquidRejectCode {
237    pub fn from_api_error(error_message: &str) -> Self {
238        // TODO: Research Hyperliquid's actual error response format
239        // Check if they provide:
240        // - Numeric error codes
241        // - Error type/category fields
242        // - Structured error objects
243        // If so, parse those instead of string matching
244
245        // For now, we still fall back to string matching, but this method provides
246        // a clear migration path when better error information becomes available
247        Self::from_error_string_internal(error_message)
248    }
249
250    /// Internal string parsing method - not exposed publicly.
251    ///
252    /// This encapsulates the fragile string matching logic and makes it clear
253    /// that it should only be used internally until we have better error handling.
254    fn from_error_string_internal(error: &str) -> Self {
255        match error {
256            s if s.contains("tick size") => HyperliquidRejectCode::Tick,
257            s if s.contains("minimum value of $10") => HyperliquidRejectCode::MinTradeNtl,
258            s if s.contains("minimum value of 10") => HyperliquidRejectCode::MinTradeSpotNtl,
259            s if s.contains("Insufficient margin") => HyperliquidRejectCode::PerpMargin,
260            s if s.contains("Reduce only order would increase") => {
261                HyperliquidRejectCode::ReduceOnly
262            }
263            s if s.contains("Post only order would have immediately matched") => {
264                HyperliquidRejectCode::BadAloPx
265            }
266            s if s.contains("could not immediately match") => HyperliquidRejectCode::IocCancel,
267            s if s.contains("Invalid TP/SL price") => HyperliquidRejectCode::BadTriggerPx,
268            s if s.contains("No liquidity available for market order") => {
269                HyperliquidRejectCode::MarketOrderNoLiquidity
270            }
271            s if s.contains("PositionIncreaseAtOpenInterestCap") => {
272                HyperliquidRejectCode::PositionIncreaseAtOpenInterestCap
273            }
274            s if s.contains("PositionFlipAtOpenInterestCap") => {
275                HyperliquidRejectCode::PositionFlipAtOpenInterestCap
276            }
277            s if s.contains("TooAggressiveAtOpenInterestCap") => {
278                HyperliquidRejectCode::TooAggressiveAtOpenInterestCap
279            }
280            s if s.contains("OpenInterestIncrease") => HyperliquidRejectCode::OpenInterestIncrease,
281            s if s.contains("Insufficient spot balance") => {
282                HyperliquidRejectCode::InsufficientSpotBalance
283            }
284            s if s.contains("Oracle") => HyperliquidRejectCode::Oracle,
285            s if s.contains("max position") => HyperliquidRejectCode::PerpMaxPosition,
286            s if s.contains("MissingOrder") => HyperliquidRejectCode::MissingOrder,
287            s => HyperliquidRejectCode::Unknown(s.to_string()),
288        }
289    }
290
291    /// Parses reject code from error string.
292    ///
293    /// **Deprecated**: This method uses substring matching which is fragile and not robust.
294    /// Use `from_api_error()` instead, which provides a migration path for structured error handling.
295    #[deprecated(
296        since = "0.50.0",
297        note = "String parsing is fragile; use HyperliquidRejectCode::from_api_error() instead"
298    )]
299    pub fn from_error_string(error: &str) -> Self {
300        Self::from_error_string_internal(error)
301    }
302}
303
304////////////////////////////////////////////////////////////////////////////////
305// Tests
306////////////////////////////////////////////////////////////////////////////////
307
308#[cfg(test)]
309mod tests {
310    use rstest::rstest;
311    use serde_json;
312
313    use super::*;
314
315    #[rstest]
316    fn test_side_serde() {
317        let buy_side = HyperliquidSide::Buy;
318        let sell_side = HyperliquidSide::Sell;
319
320        assert_eq!(serde_json::to_string(&buy_side).unwrap(), "\"B\"");
321        assert_eq!(serde_json::to_string(&sell_side).unwrap(), "\"A\"");
322
323        assert_eq!(
324            serde_json::from_str::<HyperliquidSide>("\"B\"").unwrap(),
325            HyperliquidSide::Buy
326        );
327        assert_eq!(
328            serde_json::from_str::<HyperliquidSide>("\"A\"").unwrap(),
329            HyperliquidSide::Sell
330        );
331    }
332
333    #[rstest]
334    fn test_side_from_order_side() {
335        // Test conversion from OrderSide to HyperliquidSide
336        assert_eq!(HyperliquidSide::from(OrderSide::Buy), HyperliquidSide::Buy);
337        assert_eq!(
338            HyperliquidSide::from(OrderSide::Sell),
339            HyperliquidSide::Sell
340        );
341    }
342
343    #[rstest]
344    fn test_order_side_from_hyperliquid_side() {
345        // Test conversion from HyperliquidSide to OrderSide
346        assert_eq!(OrderSide::from(HyperliquidSide::Buy), OrderSide::Buy);
347        assert_eq!(OrderSide::from(HyperliquidSide::Sell), OrderSide::Sell);
348    }
349
350    #[rstest]
351    fn test_aggressor_side_from_hyperliquid_side() {
352        // Test conversion from HyperliquidSide to AggressorSide
353        assert_eq!(
354            AggressorSide::from(HyperliquidSide::Buy),
355            AggressorSide::Buyer
356        );
357        assert_eq!(
358            AggressorSide::from(HyperliquidSide::Sell),
359            AggressorSide::Seller
360        );
361    }
362
363    #[rstest]
364    fn test_time_in_force_serde() {
365        let test_cases = [
366            (HyperliquidTimeInForce::Alo, "\"Alo\""),
367            (HyperliquidTimeInForce::Ioc, "\"Ioc\""),
368            (HyperliquidTimeInForce::Gtc, "\"Gtc\""),
369        ];
370
371        for (tif, expected_json) in test_cases {
372            assert_eq!(serde_json::to_string(&tif).unwrap(), expected_json);
373            assert_eq!(
374                serde_json::from_str::<HyperliquidTimeInForce>(expected_json).unwrap(),
375                tif
376            );
377        }
378    }
379
380    #[rstest]
381    fn test_liquidity_flag_from_crossed() {
382        assert_eq!(
383            HyperliquidLiquidityFlag::from(true),
384            HyperliquidLiquidityFlag::Taker
385        );
386        assert_eq!(
387            HyperliquidLiquidityFlag::from(false),
388            HyperliquidLiquidityFlag::Maker
389        );
390    }
391
392    #[rstest]
393    #[allow(deprecated)]
394    fn test_reject_code_from_error_string() {
395        let test_cases = [
396            (
397                "Price must be divisible by tick size.",
398                HyperliquidRejectCode::Tick,
399            ),
400            (
401                "Order must have minimum value of $10.",
402                HyperliquidRejectCode::MinTradeNtl,
403            ),
404            (
405                "Insufficient margin to place order.",
406                HyperliquidRejectCode::PerpMargin,
407            ),
408            (
409                "Post only order would have immediately matched, bbo was 1.23",
410                HyperliquidRejectCode::BadAloPx,
411            ),
412            (
413                "Some unknown error",
414                HyperliquidRejectCode::Unknown("Some unknown error".to_string()),
415            ),
416        ];
417
418        for (error_str, expected_code) in test_cases {
419            assert_eq!(
420                HyperliquidRejectCode::from_error_string(error_str),
421                expected_code
422            );
423        }
424    }
425
426    #[rstest]
427    fn test_reject_code_from_api_error() {
428        let test_cases = [
429            (
430                "Price must be divisible by tick size.",
431                HyperliquidRejectCode::Tick,
432            ),
433            (
434                "Order must have minimum value of $10.",
435                HyperliquidRejectCode::MinTradeNtl,
436            ),
437            (
438                "Insufficient margin to place order.",
439                HyperliquidRejectCode::PerpMargin,
440            ),
441            (
442                "Post only order would have immediately matched, bbo was 1.23",
443                HyperliquidRejectCode::BadAloPx,
444            ),
445            (
446                "Some unknown error",
447                HyperliquidRejectCode::Unknown("Some unknown error".to_string()),
448            ),
449        ];
450
451        for (error_str, expected_code) in test_cases {
452            assert_eq!(
453                HyperliquidRejectCode::from_api_error(error_str),
454                expected_code
455            );
456        }
457    }
458
459    #[rstest]
460    fn test_reduce_only() {
461        let reduce_only = HyperliquidReduceOnly::new(true);
462
463        assert!(reduce_only.is_reduce_only());
464
465        let json = serde_json::to_string(&reduce_only).unwrap();
466        assert_eq!(json, "true");
467
468        let parsed: HyperliquidReduceOnly = serde_json::from_str(&json).unwrap();
469        assert_eq!(parsed, reduce_only);
470    }
471}