Skip to main content

nautilus_hyperliquid/common/
enums.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
16use std::{fmt::Display, str::FromStr};
17
18use nautilus_model::enums::{AggressorSide, OrderSide, OrderStatus, OrderType};
19use serde::{Deserialize, Serialize};
20use strum::{AsRefStr, Display, EnumIter, EnumString};
21
22use super::{consts::HYPERLIQUID_POST_ONLY_WOULD_MATCH, parse::OUTCOME_SYMBOL_SUFFIX};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub enum HyperliquidBarInterval {
26    #[serde(rename = "1m")]
27    OneMinute,
28    #[serde(rename = "3m")]
29    ThreeMinutes,
30    #[serde(rename = "5m")]
31    FiveMinutes,
32    #[serde(rename = "15m")]
33    FifteenMinutes,
34    #[serde(rename = "30m")]
35    ThirtyMinutes,
36    #[serde(rename = "1h")]
37    OneHour,
38    #[serde(rename = "2h")]
39    TwoHours,
40    #[serde(rename = "4h")]
41    FourHours,
42    #[serde(rename = "8h")]
43    EightHours,
44    #[serde(rename = "12h")]
45    TwelveHours,
46    #[serde(rename = "1d")]
47    OneDay,
48    #[serde(rename = "3d")]
49    ThreeDays,
50    #[serde(rename = "1w")]
51    OneWeek,
52    #[serde(rename = "1M")]
53    OneMonth,
54}
55
56impl HyperliquidBarInterval {
57    pub fn as_str(&self) -> &'static str {
58        match self {
59            Self::OneMinute => "1m",
60            Self::ThreeMinutes => "3m",
61            Self::FiveMinutes => "5m",
62            Self::FifteenMinutes => "15m",
63            Self::ThirtyMinutes => "30m",
64            Self::OneHour => "1h",
65            Self::TwoHours => "2h",
66            Self::FourHours => "4h",
67            Self::EightHours => "8h",
68            Self::TwelveHours => "12h",
69            Self::OneDay => "1d",
70            Self::ThreeDays => "3d",
71            Self::OneWeek => "1w",
72            Self::OneMonth => "1M",
73        }
74    }
75}
76
77impl FromStr for HyperliquidBarInterval {
78    type Err = anyhow::Error;
79
80    fn from_str(s: &str) -> Result<Self, Self::Err> {
81        match s {
82            "1m" => Ok(Self::OneMinute),
83            "3m" => Ok(Self::ThreeMinutes),
84            "5m" => Ok(Self::FiveMinutes),
85            "15m" => Ok(Self::FifteenMinutes),
86            "30m" => Ok(Self::ThirtyMinutes),
87            "1h" => Ok(Self::OneHour),
88            "2h" => Ok(Self::TwoHours),
89            "4h" => Ok(Self::FourHours),
90            "8h" => Ok(Self::EightHours),
91            "12h" => Ok(Self::TwelveHours),
92            "1d" => Ok(Self::OneDay),
93            "3d" => Ok(Self::ThreeDays),
94            "1w" => Ok(Self::OneWeek),
95            "1M" => Ok(Self::OneMonth),
96            _ => anyhow::bail!("Invalid Hyperliquid bar interval: {s}"),
97        }
98    }
99}
100
101impl Display for HyperliquidBarInterval {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        write!(f, "{}", self.as_str())
104    }
105}
106
107/// Represents the order side (Buy or Sell).
108#[derive(
109    Copy,
110    Clone,
111    Debug,
112    Display,
113    PartialEq,
114    Eq,
115    Hash,
116    AsRefStr,
117    EnumIter,
118    EnumString,
119    Serialize,
120    Deserialize,
121)]
122#[serde(rename_all = "UPPERCASE")]
123#[strum(serialize_all = "UPPERCASE")]
124pub enum HyperliquidSide {
125    #[serde(rename = "B")]
126    Buy,
127    #[serde(rename = "A")]
128    Sell,
129}
130
131impl From<OrderSide> for HyperliquidSide {
132    fn from(value: OrderSide) -> Self {
133        match value {
134            OrderSide::Buy => Self::Buy,
135            OrderSide::Sell => Self::Sell,
136        }
137    }
138}
139
140impl From<HyperliquidSide> for OrderSide {
141    fn from(value: HyperliquidSide) -> Self {
142        match value {
143            HyperliquidSide::Buy => Self::Buy,
144            HyperliquidSide::Sell => Self::Sell,
145        }
146    }
147}
148
149impl From<HyperliquidSide> for AggressorSide {
150    fn from(value: HyperliquidSide) -> Self {
151        match value {
152            HyperliquidSide::Buy => Self::Buy,
153            HyperliquidSide::Sell => Self::Sell,
154        }
155    }
156}
157
158/// Represents the time in force for limit orders.
159#[derive(
160    Copy,
161    Clone,
162    Debug,
163    Display,
164    PartialEq,
165    Eq,
166    Hash,
167    AsRefStr,
168    EnumIter,
169    EnumString,
170    Serialize,
171    Deserialize,
172)]
173#[serde(rename_all = "PascalCase")]
174#[strum(serialize_all = "PascalCase")]
175pub enum HyperliquidTimeInForce {
176    /// Add Liquidity Only - post-only order.
177    Alo,
178    /// Immediate or Cancel - fill immediately or cancel.
179    Ioc,
180    /// Good Till Cancel - remain on book until filled or cancelled.
181    Gtc,
182    /// UI market order reported on `orderStatus` and `historicalOrders`.
183    FrontendMarket,
184    /// Liquidation market order reported on historical order queries.
185    LiquidationMarket,
186}
187
188/// Represents the order type configuration.
189#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
190#[serde(tag = "type", rename_all = "lowercase")]
191pub enum HyperliquidOrderType {
192    /// Limit order with time-in-force.
193    #[serde(rename = "limit")]
194    Limit { tif: HyperliquidTimeInForce },
195
196    /// Trigger order (stop or take profit).
197    #[serde(rename = "trigger")]
198    Trigger {
199        #[serde(rename = "isMarket")]
200        is_market: bool,
201        #[serde(rename = "triggerPx")]
202        trigger_px: String,
203        tpsl: HyperliquidTpSl,
204    },
205}
206
207/// Represents the take profit / stop loss type.
208#[derive(
209    Copy,
210    Clone,
211    Debug,
212    Display,
213    PartialEq,
214    Eq,
215    Hash,
216    AsRefStr,
217    EnumIter,
218    EnumString,
219    Serialize,
220    Deserialize,
221)]
222#[cfg_attr(
223    feature = "python",
224    pyo3::pyclass(
225        module = "nautilus_trader.adapters.hyperliquid",
226        from_py_object,
227        rename_all = "SCREAMING_SNAKE_CASE",
228    )
229)]
230#[cfg_attr(
231    feature = "python",
232    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.adapters.hyperliquid")
233)]
234#[serde(rename_all = "lowercase")]
235#[strum(serialize_all = "lowercase")]
236pub enum HyperliquidTpSl {
237    /// Take Profit.
238    Tp,
239    /// Stop Loss.
240    Sl,
241}
242
243/// Represents conditional/trigger order types.
244///
245/// Hyperliquid supports various conditional order types that trigger
246/// based on market conditions. These map to Nautilus OrderType variants.
247#[derive(
248    Copy,
249    Clone,
250    Debug,
251    Display,
252    PartialEq,
253    Eq,
254    Hash,
255    AsRefStr,
256    EnumIter,
257    EnumString,
258    Serialize,
259    Deserialize,
260)]
261#[cfg_attr(
262    feature = "python",
263    pyo3::pyclass(
264        module = "nautilus_trader.adapters.hyperliquid",
265        from_py_object,
266        rename_all = "SCREAMING_SNAKE_CASE",
267    )
268)]
269#[cfg_attr(
270    feature = "python",
271    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.adapters.hyperliquid")
272)]
273#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
274#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
275pub enum HyperliquidConditionalOrderType {
276    /// Stop market order (protective stop with market execution).
277    StopMarket,
278    /// Stop limit order (protective stop with limit price).
279    StopLimit,
280    /// Take profit market order (profit-taking with market execution).
281    TakeProfitMarket,
282    /// Take profit limit order (profit-taking with limit price).
283    TakeProfitLimit,
284    /// Trailing stop market order (dynamic stop with market execution).
285    TrailingStopMarket,
286    /// Trailing stop limit order (dynamic stop with limit price).
287    TrailingStopLimit,
288}
289
290impl From<HyperliquidConditionalOrderType> for OrderType {
291    fn from(value: HyperliquidConditionalOrderType) -> Self {
292        match value {
293            HyperliquidConditionalOrderType::StopMarket => Self::StopMarket,
294            HyperliquidConditionalOrderType::StopLimit => Self::StopLimit,
295            HyperliquidConditionalOrderType::TakeProfitMarket => Self::MarketIfTouched,
296            HyperliquidConditionalOrderType::TakeProfitLimit => Self::LimitIfTouched,
297            HyperliquidConditionalOrderType::TrailingStopMarket => Self::TrailingStopMarket,
298            HyperliquidConditionalOrderType::TrailingStopLimit => Self::TrailingStopLimit,
299        }
300    }
301}
302
303impl From<OrderType> for HyperliquidConditionalOrderType {
304    fn from(value: OrderType) -> Self {
305        match value {
306            OrderType::StopMarket => Self::StopMarket,
307            OrderType::StopLimit => Self::StopLimit,
308            OrderType::MarketIfTouched => Self::TakeProfitMarket,
309            OrderType::LimitIfTouched => Self::TakeProfitLimit,
310            OrderType::TrailingStopMarket => Self::TrailingStopMarket,
311            OrderType::TrailingStopLimit => Self::TrailingStopLimit,
312            _ => panic!("Unsupported OrderType for conditional orders: {value:?}"),
313        }
314    }
315}
316
317/// Represents trailing offset types for trailing stop orders.
318///
319/// Trailing stops adjust dynamically based on market movement:
320/// - Price: Fixed price offset (e.g., $100)
321/// - Percentage: Percentage offset (e.g., 5%)
322/// - BasisPoints: Basis points offset (e.g., 250 bps = 2.5%)
323#[derive(
324    Copy,
325    Clone,
326    Debug,
327    Display,
328    PartialEq,
329    Eq,
330    Hash,
331    AsRefStr,
332    EnumIter,
333    EnumString,
334    Serialize,
335    Deserialize,
336)]
337#[cfg_attr(
338    feature = "python",
339    pyo3::pyclass(
340        module = "nautilus_trader.adapters.hyperliquid",
341        from_py_object,
342        rename_all = "SCREAMING_SNAKE_CASE",
343    )
344)]
345#[cfg_attr(
346    feature = "python",
347    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.adapters.hyperliquid")
348)]
349#[serde(rename_all = "lowercase")]
350#[strum(serialize_all = "lowercase")]
351pub enum HyperliquidTrailingOffsetType {
352    /// Fixed price offset.
353    Price,
354    /// Percentage offset.
355    Percentage,
356    /// Basis points offset (1 bp = 0.01%).
357    #[serde(rename = "basispoints")]
358    #[strum(serialize = "basispoints")]
359    BasisPoints,
360}
361
362/// Represents the reduce only flag wrapper.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
364#[serde(transparent)]
365pub struct HyperliquidReduceOnly(pub bool);
366
367impl HyperliquidReduceOnly {
368    /// Creates a new reduce only flag.
369    pub fn new(reduce_only: bool) -> Self {
370        Self(reduce_only)
371    }
372
373    /// Returns whether this is a reduce only order.
374    pub fn is_reduce_only(&self) -> bool {
375        self.0
376    }
377}
378
379/// Represents the liquidity flag indicating maker or taker.
380#[derive(
381    Copy,
382    Clone,
383    Debug,
384    Display,
385    PartialEq,
386    Eq,
387    Hash,
388    AsRefStr,
389    EnumIter,
390    EnumString,
391    Serialize,
392    Deserialize,
393)]
394#[serde(rename_all = "lowercase")]
395#[strum(serialize_all = "lowercase")]
396pub enum HyperliquidLiquidityFlag {
397    Maker,
398    Taker,
399}
400
401impl From<bool> for HyperliquidLiquidityFlag {
402    /// Converts from `crossed` field in fill responses.
403    ///
404    /// `true` (crossed) -> Taker, `false` -> Maker
405    fn from(crossed: bool) -> Self {
406        if crossed { Self::Taker } else { Self::Maker }
407    }
408}
409
410/// Hyperliquid liquidation method.
411#[derive(
412    Clone, Copy, Debug, Display, PartialEq, Eq, Hash, Serialize, Deserialize, AsRefStr, EnumString,
413)]
414#[serde(rename_all = "lowercase")]
415#[strum(serialize_all = "lowercase")]
416pub enum HyperliquidLiquidationMethod {
417    Market,
418    Backstop,
419    #[serde(other)]
420    Unknown,
421}
422
423/// Hyperliquid position type/mode.
424#[derive(
425    Clone, Copy, Debug, Display, PartialEq, Eq, Hash, Serialize, Deserialize, AsRefStr, EnumString,
426)]
427#[serde(rename_all = "camelCase")]
428#[strum(serialize_all = "camelCase")]
429pub enum HyperliquidPositionType {
430    OneWay,
431    #[serde(other)]
432    Unknown,
433}
434
435/// Hyperliquid TWAP order status.
436#[derive(
437    Clone, Copy, Debug, Display, PartialEq, Eq, Hash, Serialize, Deserialize, AsRefStr, EnumString,
438)]
439#[serde(rename_all = "lowercase")]
440#[strum(serialize_all = "lowercase")]
441pub enum HyperliquidTwapStatus {
442    Activated,
443    Terminated,
444    Finished,
445    Error,
446    #[serde(other)]
447    Unknown,
448}
449
450#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
451#[serde(untagged)]
452pub enum HyperliquidRejectCode {
453    /// Price must be divisible by tick size.
454    Tick,
455    /// Order must have minimum value of $10.
456    MinTradeNtl,
457    /// Order must have minimum value of 10 {quote_token}.
458    MinTradeSpotNtl,
459    /// Insufficient margin to place order.
460    PerpMargin,
461    /// Reduce only order would increase position.
462    ReduceOnly,
463    /// Post only order would have immediately matched.
464    BadAloPx,
465    /// Order could not immediately match.
466    IocCancel,
467    /// Invalid TP/SL price.
468    BadTriggerPx,
469    /// No liquidity available for market order.
470    MarketOrderNoLiquidity,
471    /// Position increase at open interest cap.
472    PositionIncreaseAtOpenInterestCap,
473    /// Position flip at open interest cap.
474    PositionFlipAtOpenInterestCap,
475    /// Too aggressive at open interest cap.
476    TooAggressiveAtOpenInterestCap,
477    /// Open interest increase.
478    OpenInterestIncrease,
479    /// Insufficient spot balance.
480    InsufficientSpotBalance,
481    /// Oracle issue.
482    Oracle,
483    /// Perp max position.
484    PerpMaxPosition,
485    /// Missing order.
486    MissingOrder,
487    /// Unknown reject reason with raw error message.
488    Unknown(String),
489}
490
491impl HyperliquidRejectCode {
492    /// Parse reject code from Hyperliquid API error message.
493    pub fn from_api_error(error_message: &str) -> Self {
494        Self::from_error_string_internal(error_message)
495    }
496
497    fn from_error_string_internal(error: &str) -> Self {
498        // Normalize: trim whitespace and convert to lowercase for robust matching
499        let normalized = error.trim().to_lowercase();
500
501        match normalized.as_str() {
502            // Tick size validation errors
503            s if s.contains("tick size") => Self::Tick,
504
505            // Minimum notional value errors (perp: $10, spot: 10 USDC)
506            s if s.contains("minimum value of $10") => Self::MinTradeNtl,
507            s if s.contains("minimum value of 10") => Self::MinTradeSpotNtl,
508
509            // Margin errors
510            s if s.contains("insufficient margin") => Self::PerpMargin,
511
512            // Reduce-only order violations
513            s if s.contains("reduce only order would increase")
514                || s.contains("reduce-only order would increase") =>
515            {
516                Self::ReduceOnly
517            }
518
519            // Post-only order matching errors
520            s if s.contains(&HYPERLIQUID_POST_ONLY_WOULD_MATCH.to_lowercase())
521                || s.contains("post-only order would have immediately matched") =>
522            {
523                Self::BadAloPx
524            }
525
526            // IOC (Immediate-or-Cancel) order errors
527            s if s.contains("could not immediately match") => Self::IocCancel,
528
529            // TP/SL trigger price errors
530            s if s.contains("invalid tp/sl price") => Self::BadTriggerPx,
531
532            // Market order liquidity errors
533            s if s.contains("no liquidity available for market order") => {
534                Self::MarketOrderNoLiquidity
535            }
536
537            // Open interest cap errors (various types)
538            // Note: These patterns are case-insensitive due to normalization
539            s if s.contains("positionincreaseatopeninterestcap") => {
540                Self::PositionIncreaseAtOpenInterestCap
541            }
542            s if s.contains("positionflipatopeninterestcap") => Self::PositionFlipAtOpenInterestCap,
543            s if s.contains("tooaggressiveatopeninterestcap") => {
544                Self::TooAggressiveAtOpenInterestCap
545            }
546            s if s.contains("openinterestincrease") => Self::OpenInterestIncrease,
547
548            // Spot balance errors
549            s if s.contains("insufficient spot balance") => Self::InsufficientSpotBalance,
550
551            // Oracle errors
552            s if s.contains("oracle") => Self::Oracle,
553
554            // Position size limit errors
555            s if s.contains("max position") => Self::PerpMaxPosition,
556
557            // Missing order errors (cancel/modify non-existent order)
558            s if s.contains("missingorder") => Self::MissingOrder,
559
560            // Unknown error - log for monitoring and return with original message
561            _ => {
562                log::warn!(
563                    "Unknown Hyperliquid error pattern (consider updating error parsing): {error}" // Use original error, not normalized
564                );
565                Self::Unknown(error.to_string())
566            }
567        }
568    }
569
570    /// Parses reject code from error string.
571    ///
572    /// **Deprecated**: This method uses substring matching which is fragile and not robust.
573    /// Use `from_api_error()` instead, which provides a migration path for structured error handling.
574    #[deprecated(
575        since = "0.50.0",
576        note = "String parsing is fragile; use HyperliquidRejectCode::from_api_error() instead"
577    )]
578    pub fn from_error_string(error: &str) -> Self {
579        Self::from_error_string_internal(error)
580    }
581}
582
583/// Represents Hyperliquid order status from API responses.
584///
585/// Hyperliquid uses lowercase status values with camelCase for compound words.
586#[derive(
587    Copy,
588    Clone,
589    Debug,
590    Display,
591    PartialEq,
592    Eq,
593    Hash,
594    AsRefStr,
595    EnumIter,
596    EnumString,
597    Serialize,
598    Deserialize,
599)]
600pub enum HyperliquidOrderStatus {
601    /// Order has been accepted and is open.
602    #[serde(rename = "open")]
603    Open,
604    /// Order has been accepted and is open (alternative representation).
605    #[serde(rename = "accepted")]
606    Accepted,
607    /// Order has been triggered (for conditional orders).
608    #[serde(rename = "triggered")]
609    Triggered,
610    /// Order has been completely filled.
611    #[serde(rename = "filled")]
612    Filled,
613    /// Order has been canceled.
614    #[serde(rename = "canceled")]
615    Canceled,
616    /// Order was rejected by the exchange.
617    #[serde(rename = "rejected")]
618    Rejected,
619    // Specific cancel reasons - all map to CANCELED status
620    /// Order canceled due to margin requirements.
621    #[serde(rename = "marginCanceled")]
622    MarginCanceled,
623    /// Order canceled due to vault withdrawal.
624    #[serde(rename = "vaultWithdrawalCanceled")]
625    VaultWithdrawalCanceled,
626    /// Order canceled due to open interest cap.
627    #[serde(rename = "openInterestCapCanceled")]
628    OpenInterestCapCanceled,
629    /// Order canceled due to self trade prevention.
630    #[serde(rename = "selfTradeCanceled")]
631    SelfTradeCanceled,
632    /// Order canceled due to reduce only constraint.
633    #[serde(rename = "reduceOnlyCanceled")]
634    ReduceOnlyCanceled,
635    /// Order canceled because sibling order was filled.
636    #[serde(rename = "siblingFilledCanceled")]
637    SiblingFilledCanceled,
638    /// Order canceled due to delisting.
639    #[serde(rename = "delistedCanceled")]
640    DelistedCanceled,
641    /// Order canceled due to liquidation.
642    #[serde(rename = "liquidatedCanceled")]
643    LiquidatedCanceled,
644    /// Order canceled because its HIP-4 outcome settled.
645    #[serde(rename = "outcomeSettledCanceled")]
646    OutcomeSettledCanceled,
647    /// Order was scheduled for cancel.
648    #[serde(rename = "scheduledCancel")]
649    ScheduledCancel,
650    // Specific reject reasons - all map to REJECTED status
651    /// Order rejected due to tick size.
652    #[serde(rename = "tickRejected")]
653    TickRejected,
654    /// Order rejected due to minimum trade notional.
655    #[serde(rename = "minTradeNtlRejected")]
656    MinTradeNtlRejected,
657    /// Order rejected due to minimum spot trade notional.
658    #[serde(rename = "minTradeSpotNtlRejected")]
659    MinTradeSpotNtlRejected,
660    /// Order rejected due to perp margin.
661    #[serde(rename = "perpMarginRejected")]
662    PerpMarginRejected,
663    /// Order rejected due to reduce only constraint.
664    #[serde(rename = "reduceOnlyRejected")]
665    ReduceOnlyRejected,
666    /// Order rejected due to bad ALO price.
667    #[serde(rename = "badAloPxRejected")]
668    BadAloPxRejected,
669    /// IOC order canceled and rejected.
670    #[serde(rename = "iocCancelRejected")]
671    IocCancelRejected,
672    /// Order rejected due to bad trigger price.
673    #[serde(rename = "badTriggerPxRejected")]
674    BadTriggerPxRejected,
675    /// Market order rejected due to no liquidity.
676    #[serde(rename = "marketOrderNoLiquidityRejected")]
677    MarketOrderNoLiquidityRejected,
678    /// Order rejected due to open interest cap.
679    #[serde(rename = "positionIncreaseAtOpenInterestCapRejected")]
680    PositionIncreaseAtOpenInterestCapRejected,
681    /// Order rejected due to position flip at open interest cap.
682    #[serde(rename = "positionFlipAtOpenInterestCapRejected")]
683    PositionFlipAtOpenInterestCapRejected,
684    /// Order rejected due to too aggressive at open interest cap.
685    #[serde(rename = "tooAggressiveAtOpenInterestCapRejected")]
686    TooAggressiveAtOpenInterestCapRejected,
687    /// Order rejected due to open interest increase.
688    #[serde(rename = "openInterestIncreaseRejected")]
689    OpenInterestIncreaseRejected,
690    /// Order rejected due to insufficient spot balance.
691    #[serde(rename = "insufficientSpotBalanceRejected")]
692    InsufficientSpotBalanceRejected,
693    /// Order rejected by oracle.
694    #[serde(rename = "oracleRejected")]
695    OracleRejected,
696    /// Order rejected due to perp max position.
697    #[serde(rename = "perpMaxPositionRejected")]
698    PerpMaxPositionRejected,
699}
700
701impl From<HyperliquidOrderStatus> for OrderStatus {
702    fn from(status: HyperliquidOrderStatus) -> Self {
703        match status {
704            HyperliquidOrderStatus::Open | HyperliquidOrderStatus::Accepted => Self::Accepted,
705            HyperliquidOrderStatus::Triggered => Self::Triggered,
706            HyperliquidOrderStatus::Filled => Self::Filled,
707            // All cancel variants map to CANCELED
708            HyperliquidOrderStatus::Canceled
709            | HyperliquidOrderStatus::MarginCanceled
710            | HyperliquidOrderStatus::VaultWithdrawalCanceled
711            | HyperliquidOrderStatus::OpenInterestCapCanceled
712            | HyperliquidOrderStatus::SelfTradeCanceled
713            | HyperliquidOrderStatus::ReduceOnlyCanceled
714            | HyperliquidOrderStatus::SiblingFilledCanceled
715            | HyperliquidOrderStatus::DelistedCanceled
716            | HyperliquidOrderStatus::LiquidatedCanceled
717            | HyperliquidOrderStatus::OutcomeSettledCanceled
718            | HyperliquidOrderStatus::ScheduledCancel => Self::Canceled,
719            // All reject variants map to REJECTED
720            HyperliquidOrderStatus::Rejected
721            | HyperliquidOrderStatus::TickRejected
722            | HyperliquidOrderStatus::MinTradeNtlRejected
723            | HyperliquidOrderStatus::MinTradeSpotNtlRejected
724            | HyperliquidOrderStatus::PerpMarginRejected
725            | HyperliquidOrderStatus::ReduceOnlyRejected
726            | HyperliquidOrderStatus::BadAloPxRejected
727            | HyperliquidOrderStatus::IocCancelRejected
728            | HyperliquidOrderStatus::BadTriggerPxRejected
729            | HyperliquidOrderStatus::MarketOrderNoLiquidityRejected
730            | HyperliquidOrderStatus::PositionIncreaseAtOpenInterestCapRejected
731            | HyperliquidOrderStatus::PositionFlipAtOpenInterestCapRejected
732            | HyperliquidOrderStatus::TooAggressiveAtOpenInterestCapRejected
733            | HyperliquidOrderStatus::OpenInterestIncreaseRejected
734            | HyperliquidOrderStatus::InsufficientSpotBalanceRejected
735            | HyperliquidOrderStatus::OracleRejected
736            | HyperliquidOrderStatus::PerpMaxPositionRejected => Self::Rejected,
737        }
738    }
739}
740
741impl HyperliquidOrderStatus {
742    /// Returns the venue rejection text represented by a structured status.
743    #[must_use]
744    pub const fn rejection_reason(self) -> Option<&'static str> {
745        match self {
746            Self::BadAloPxRejected => Some(HYPERLIQUID_POST_ONLY_WOULD_MATCH),
747            Self::ReduceOnlyRejected => Some("Reduce only order would increase position."),
748            Self::IocCancelRejected => {
749                Some("Order could not immediately match against any resting orders")
750            }
751            _ => None,
752        }
753    }
754}
755
756/// Represents the direction of a fill (open/close position).
757///
758/// For perpetuals:
759/// - OpenLong: Opening a long position
760/// - OpenShort: Opening a short position
761/// - CloseLong: Closing an existing long position
762/// - CloseShort: Closing an existing short position
763///
764/// For spot:
765/// - Sell: Selling an asset
766#[derive(
767    Copy,
768    Clone,
769    Debug,
770    Display,
771    PartialEq,
772    Eq,
773    Hash,
774    AsRefStr,
775    EnumIter,
776    EnumString,
777    Serialize,
778    Deserialize,
779)]
780#[serde(rename_all = "PascalCase")]
781#[strum(serialize_all = "PascalCase")]
782pub enum HyperliquidFillDirection {
783    /// Opening a long position.
784    #[serde(rename = "Open Long")]
785    #[strum(serialize = "Open Long")]
786    OpenLong,
787    /// Opening a short position.
788    #[serde(rename = "Open Short")]
789    #[strum(serialize = "Open Short")]
790    OpenShort,
791    /// Closing an existing long position.
792    #[serde(rename = "Close Long")]
793    #[strum(serialize = "Close Long")]
794    CloseLong,
795    /// Closing an existing short position.
796    #[serde(rename = "Close Short")]
797    #[strum(serialize = "Close Short")]
798    CloseShort,
799    /// Flipping from long to short (position reversal).
800    #[serde(rename = "Long > Short")]
801    #[strum(serialize = "Long > Short")]
802    LongToShort,
803    /// Flipping from short to long (position reversal).
804    #[serde(rename = "Short > Long")]
805    #[strum(serialize = "Short > Long")]
806    ShortToLong,
807    /// Auto-deleveraging counterparty fill (perp ADL event).
808    #[serde(rename = "Auto-Deleveraging")]
809    #[strum(serialize = "Auto-Deleveraging")]
810    AutoDeleveraging,
811    /// Vault-leader netting of child vault positions.
812    #[serde(rename = "Net Child Vaults")]
813    #[strum(serialize = "Net Child Vaults")]
814    NetChildVaults,
815    /// Buying an asset (spot only).
816    Buy,
817    /// Selling an asset (spot only).
818    Sell,
819    /// HIP-1 spot dust conversion: sub-lot spot balances sold to the quote token.
820    #[serde(rename = "Spot Dust Conversion")]
821    #[strum(serialize = "Spot Dust Conversion")]
822    SpotDustConversion,
823    /// HIP-4 outcome settlement; venue closes side-token holdings at the
824    /// resolved value (1 quote token for the winning side, 0 for the loser).
825    #[serde(rename = "Settlement")]
826    #[strum(serialize = "Settlement")]
827    Settlement,
828    /// HIP-4 `userOutcome / splitOutcome`: minting paired Yes + No side tokens
829    /// from quote tokens. Venue emits one fill per side at the mid price.
830    #[serde(rename = "Split Outcome")]
831    #[strum(serialize = "Split Outcome")]
832    SplitOutcome,
833    /// HIP-4 `userOutcome / mergeOutcome`: burning paired Yes + No side tokens
834    /// back into quote tokens. Reverse of [`Self::SplitOutcome`].
835    #[serde(rename = "Merge Outcome")]
836    #[strum(serialize = "Merge Outcome")]
837    MergeOutcome,
838    /// HIP-4 `userOutcome / mergeQuestion`: burning one Yes share of every
839    /// outcome in a multi-outcome question for the equivalent quote tokens.
840    #[serde(rename = "Merge Question")]
841    #[strum(serialize = "Merge Question")]
842    MergeQuestion,
843    /// HIP-4 `userOutcome / negateOutcome`: swapping `No` shares of one
844    /// outcome for `Yes` shares of every other outcome in the same question.
845    #[serde(rename = "Negate Outcome")]
846    #[strum(serialize = "Negate Outcome")]
847    NegateOutcome,
848    /// Catch-all for unmodeled fill directions; informational only.
849    #[serde(other)]
850    Unknown,
851}
852
853/// Represents info request types for the Hyperliquid info endpoint.
854///
855/// These correspond to the "type" field in info endpoint requests.
856#[derive(
857    Copy,
858    Clone,
859    Debug,
860    Display,
861    PartialEq,
862    Eq,
863    Hash,
864    AsRefStr,
865    EnumIter,
866    EnumString,
867    Serialize,
868    Deserialize,
869)]
870#[serde(rename_all = "camelCase")]
871#[strum(serialize_all = "camelCase")]
872pub enum HyperliquidInfoRequestType {
873    /// Get metadata about available markets.
874    Meta,
875    /// Get spot metadata (tokens and pairs).
876    SpotMeta,
877    /// Get metadata with asset contexts (for price precision).
878    MetaAndAssetCtxs,
879    /// Get spot metadata with asset contexts.
880    SpotMetaAndAssetCtxs,
881    /// Get outcome metadata.
882    OutcomeMeta,
883    /// Get L2 order book for a coin.
884    L2Book,
885    /// Get all mid prices.
886    AllMids,
887    /// Get recent public trades for a coin.
888    RecentTrades,
889    /// Get user fills.
890    UserFills,
891    /// Get user fills by time range.
892    UserFillsByTime,
893    /// Get order status for a user.
894    OrderStatus,
895    /// Get all open orders for a user.
896    OpenOrders,
897    /// Get frontend open orders (includes more detail).
898    FrontendOpenOrders,
899    /// Get user state (balances, positions, margin).
900    ClearinghouseState,
901    /// Get spot clearinghouse state.
902    SpotClearinghouseState,
903    /// Get exchange status.
904    ExchangeStatus,
905    /// Get candle/bar data snapshot.
906    CandleSnapshot,
907    /// Get candle/bar data (WS post).
908    Candle,
909    /// Get historical orders.
910    HistoricalOrders,
911    /// Get funding history.
912    FundingHistory,
913    /// Get user funding.
914    UserFunding,
915    /// Get non-user funding updates.
916    NonUserFundingUpdates,
917    /// Get TWAP history.
918    TwapHistory,
919    /// Get user TWAP slice fills.
920    UserTwapSliceFills,
921    /// Get user TWAP slice fills by time range.
922    UserTwapSliceFillsByTime,
923    /// Get user rate limit.
924    UserRateLimit,
925    /// Get user role.
926    UserRole,
927    /// Get delegator history.
928    DelegatorHistory,
929    /// Get delegator rewards.
930    DelegatorRewards,
931    /// Get validator stats.
932    ValidatorStats,
933    /// Get user fee schedule and effective rates.
934    UserFees,
935    /// Get the list of perp dex descriptors.
936    PerpDexs,
937    /// Get metadata for all perp dexes (standard + HIP-3).
938    AllPerpMetas,
939    /// Get the account abstraction mode for a user.
940    UserAbstraction,
941}
942
943impl HyperliquidInfoRequestType {
944    pub fn as_str(&self) -> &'static str {
945        match self {
946            Self::Meta => "meta",
947            Self::SpotMeta => "spotMeta",
948            Self::MetaAndAssetCtxs => "metaAndAssetCtxs",
949            Self::SpotMetaAndAssetCtxs => "spotMetaAndAssetCtxs",
950            Self::OutcomeMeta => "outcomeMeta",
951            Self::L2Book => "l2Book",
952            Self::AllMids => "allMids",
953            Self::RecentTrades => "recentTrades",
954            Self::UserFills => "userFills",
955            Self::UserFillsByTime => "userFillsByTime",
956            Self::OrderStatus => "orderStatus",
957            Self::OpenOrders => "openOrders",
958            Self::FrontendOpenOrders => "frontendOpenOrders",
959            Self::ClearinghouseState => "clearinghouseState",
960            Self::SpotClearinghouseState => "spotClearinghouseState",
961            Self::ExchangeStatus => "exchangeStatus",
962            Self::CandleSnapshot => "candleSnapshot",
963            Self::Candle => "candle",
964            Self::HistoricalOrders => "historicalOrders",
965            Self::FundingHistory => "fundingHistory",
966            Self::UserFunding => "userFunding",
967            Self::NonUserFundingUpdates => "nonUserFundingUpdates",
968            Self::TwapHistory => "twapHistory",
969            Self::UserTwapSliceFills => "userTwapSliceFills",
970            Self::UserTwapSliceFillsByTime => "userTwapSliceFillsByTime",
971            Self::UserRateLimit => "userRateLimit",
972            Self::UserRole => "userRole",
973            Self::DelegatorHistory => "delegatorHistory",
974            Self::DelegatorRewards => "delegatorRewards",
975            Self::ValidatorStats => "validatorStats",
976            Self::UserFees => "userFees",
977            Self::PerpDexs => "perpDexs",
978            Self::AllPerpMetas => "allPerpMetas",
979            Self::UserAbstraction => "userAbstraction",
980        }
981    }
982}
983
984/// Hyperliquid account abstraction mode, as returned by the `userAbstraction` info request.
985///
986/// Under a unified or portfolio margin account, every balance and hold is reported in the spot
987/// clearinghouse state and the per-dex perp states do not describe account collateral. Under
988/// the other modes USDC collateral stays in the perp clearinghouse.
989#[derive(Clone, Copy, Debug, Display, PartialEq, Eq, Hash, Serialize, Deserialize, AsRefStr)]
990#[serde(rename_all = "camelCase")]
991#[strum(serialize_all = "camelCase")]
992pub enum HyperliquidAccountAbstraction {
993    UnifiedAccount,
994    PortfolioMargin,
995    Disabled,
996    Default,
997    DexAbstraction,
998    #[serde(other)]
999    Unknown,
1000}
1001
1002impl HyperliquidAccountAbstraction {
1003    /// Returns true if account balances and holds are reported in the spot clearinghouse state.
1004    #[must_use]
1005    pub fn uses_spot_collateral(self) -> bool {
1006        matches!(self, Self::UnifiedAccount | Self::PortfolioMargin)
1007    }
1008}
1009
1010#[derive(
1011    Clone, Copy, Debug, Display, PartialEq, Eq, Hash, Serialize, Deserialize, AsRefStr, EnumString,
1012)]
1013#[serde(rename_all = "lowercase")]
1014#[strum(serialize_all = "lowercase")]
1015pub enum HyperliquidLeverageType {
1016    Cross,
1017    Isolated,
1018    #[serde(other)]
1019    Unknown,
1020}
1021
1022/// Hyperliquid product type.
1023#[derive(
1024    Copy,
1025    Clone,
1026    Debug,
1027    Display,
1028    PartialEq,
1029    Eq,
1030    Hash,
1031    AsRefStr,
1032    EnumIter,
1033    EnumString,
1034    Serialize,
1035    Deserialize,
1036)]
1037#[cfg_attr(
1038    feature = "python",
1039    pyo3::pyclass(
1040        module = "nautilus_trader.adapters.hyperliquid",
1041        from_py_object,
1042        rename_all = "SCREAMING_SNAKE_CASE",
1043    )
1044)]
1045#[cfg_attr(
1046    feature = "python",
1047    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.adapters.hyperliquid")
1048)]
1049#[serde(rename_all = "UPPERCASE")]
1050#[strum(serialize_all = "UPPERCASE")]
1051pub enum HyperliquidProductType {
1052    /// Perpetual futures.
1053    Perp,
1054    /// Spot markets.
1055    Spot,
1056    /// HIP-4 binary outcome side tokens.
1057    Outcome,
1058}
1059
1060impl HyperliquidProductType {
1061    /// Extract product type from an instrument symbol.
1062    ///
1063    /// Accepts both Nautilus instrument symbols (`{BASE}-USD-PERP`,
1064    /// `{BASE}-{QUOTE}-SPOT`, `{N}-{YES|NO}-OUTCOME`) and venue wire coin
1065    /// names (`#<encoding>` / `+<encoding>` for HIP-4 outcomes). Callers in
1066    /// the adapter pass both forms.
1067    ///
1068    /// # Errors
1069    ///
1070    /// Returns an error if symbol doesn't match any expected format.
1071    pub fn from_symbol(symbol: &str) -> anyhow::Result<Self> {
1072        if symbol.ends_with("-PERP") {
1073            Ok(Self::Perp)
1074        } else if symbol.ends_with("-SPOT") {
1075            Ok(Self::Spot)
1076        } else if symbol.ends_with(OUTCOME_SYMBOL_SUFFIX) || is_outcome_wire_symbol(symbol) {
1077            Ok(Self::Outcome)
1078        } else {
1079            anyhow::bail!("Invalid Hyperliquid symbol format: {symbol}")
1080        }
1081    }
1082}
1083
1084// Outcomes use the `#<encoding>` spot-coin form or the `+<encoding>` token
1085// form, where the encoding is `10 * outcome + side` and must parse as `u32`.
1086fn is_outcome_wire_symbol(symbol: &str) -> bool {
1087    let Some(rest) = symbol
1088        .strip_prefix('#')
1089        .or_else(|| symbol.strip_prefix('+'))
1090    else {
1091        return false;
1092    };
1093    !rest.is_empty() && rest.parse::<u32>().is_ok()
1094}
1095
1096/// Hyperliquid API environment.
1097#[derive(
1098    Copy,
1099    Clone,
1100    Debug,
1101    Default,
1102    Display,
1103    PartialEq,
1104    Eq,
1105    Hash,
1106    AsRefStr,
1107    EnumIter,
1108    EnumString,
1109    Serialize,
1110    Deserialize,
1111)]
1112#[serde(rename_all = "lowercase")]
1113#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
1114#[cfg_attr(
1115    feature = "python",
1116    pyo3::pyclass(
1117        eq,
1118        eq_int,
1119        module = "nautilus_trader.adapters.hyperliquid",
1120        from_py_object,
1121        rename_all = "SCREAMING_SNAKE_CASE",
1122    )
1123)]
1124#[cfg_attr(
1125    feature = "python",
1126    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.adapters.hyperliquid")
1127)]
1128pub enum HyperliquidEnvironment {
1129    /// Mainnet trading environment.
1130    #[default]
1131    Mainnet,
1132    /// Testnet environment.
1133    Testnet,
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use nautilus_model::enums::OrderType;
1139    use rstest::rstest;
1140    use serde_json;
1141
1142    use super::*;
1143
1144    #[rstest]
1145    fn test_side_serde() {
1146        let buy_side = HyperliquidSide::Buy;
1147        let sell_side = HyperliquidSide::Sell;
1148
1149        assert_eq!(serde_json::to_string(&buy_side).unwrap(), "\"B\"");
1150        assert_eq!(serde_json::to_string(&sell_side).unwrap(), "\"A\"");
1151
1152        assert_eq!(
1153            serde_json::from_str::<HyperliquidSide>("\"B\"").unwrap(),
1154            HyperliquidSide::Buy
1155        );
1156        assert_eq!(
1157            serde_json::from_str::<HyperliquidSide>("\"A\"").unwrap(),
1158            HyperliquidSide::Sell
1159        );
1160    }
1161
1162    #[rstest]
1163    fn test_side_from_order_side() {
1164        // Test conversion from OrderSide to HyperliquidSide
1165        assert_eq!(HyperliquidSide::from(OrderSide::Buy), HyperliquidSide::Buy);
1166        assert_eq!(
1167            HyperliquidSide::from(OrderSide::Sell),
1168            HyperliquidSide::Sell
1169        );
1170    }
1171
1172    #[rstest]
1173    fn test_order_side_from_hyperliquid_side() {
1174        // Test conversion from HyperliquidSide to OrderSide
1175        assert_eq!(OrderSide::from(HyperliquidSide::Buy), OrderSide::Buy,);
1176        assert_eq!(OrderSide::from(HyperliquidSide::Sell), OrderSide::Sell,);
1177    }
1178
1179    #[rstest]
1180    fn test_aggressor_side_from_hyperliquid_side() {
1181        // Test conversion from HyperliquidSide to AggressorSide
1182        assert_eq!(
1183            AggressorSide::from(HyperliquidSide::Buy),
1184            AggressorSide::Buy
1185        );
1186        assert_eq!(
1187            AggressorSide::from(HyperliquidSide::Sell),
1188            AggressorSide::Sell
1189        );
1190    }
1191
1192    #[rstest]
1193    fn test_time_in_force_serde() {
1194        let test_cases = [
1195            (HyperliquidTimeInForce::Alo, "\"Alo\""),
1196            (HyperliquidTimeInForce::Ioc, "\"Ioc\""),
1197            (HyperliquidTimeInForce::Gtc, "\"Gtc\""),
1198            (HyperliquidTimeInForce::FrontendMarket, "\"FrontendMarket\""),
1199            (
1200                HyperliquidTimeInForce::LiquidationMarket,
1201                "\"LiquidationMarket\"",
1202            ),
1203        ];
1204
1205        for (tif, expected_json) in test_cases {
1206            assert_eq!(serde_json::to_string(&tif).unwrap(), expected_json);
1207            assert_eq!(
1208                serde_json::from_str::<HyperliquidTimeInForce>(expected_json).unwrap(),
1209                tif
1210            );
1211        }
1212    }
1213
1214    #[rstest]
1215    fn test_info_request_type_outcome_meta_as_str() {
1216        assert_eq!(
1217            HyperliquidInfoRequestType::OutcomeMeta.as_str(),
1218            "outcomeMeta"
1219        );
1220    }
1221
1222    #[rstest]
1223    fn test_info_request_type_recent_trades_as_str() {
1224        assert_eq!(
1225            HyperliquidInfoRequestType::RecentTrades.as_str(),
1226            "recentTrades"
1227        );
1228    }
1229
1230    #[rstest]
1231    fn test_fill_direction_serde() {
1232        let cases = [
1233            (HyperliquidFillDirection::OpenLong, "\"Open Long\""),
1234            (HyperliquidFillDirection::CloseShort, "\"Close Short\""),
1235            (HyperliquidFillDirection::LongToShort, "\"Long > Short\""),
1236            (
1237                HyperliquidFillDirection::AutoDeleveraging,
1238                "\"Auto-Deleveraging\"",
1239            ),
1240            (
1241                HyperliquidFillDirection::NetChildVaults,
1242                "\"Net Child Vaults\"",
1243            ),
1244            (HyperliquidFillDirection::Buy, "\"Buy\""),
1245            (
1246                HyperliquidFillDirection::SpotDustConversion,
1247                "\"Spot Dust Conversion\"",
1248            ),
1249            (HyperliquidFillDirection::Settlement, "\"Settlement\""),
1250            (HyperliquidFillDirection::SplitOutcome, "\"Split Outcome\""),
1251            (HyperliquidFillDirection::MergeOutcome, "\"Merge Outcome\""),
1252            (
1253                HyperliquidFillDirection::MergeQuestion,
1254                "\"Merge Question\"",
1255            ),
1256            (
1257                HyperliquidFillDirection::NegateOutcome,
1258                "\"Negate Outcome\"",
1259            ),
1260        ];
1261
1262        for (variant, expected) in cases {
1263            assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
1264            assert_eq!(
1265                serde_json::from_str::<HyperliquidFillDirection>(expected).unwrap(),
1266                variant
1267            );
1268        }
1269    }
1270
1271    #[rstest]
1272    fn test_fill_direction_unknown_is_lenient() {
1273        assert_eq!(
1274            serde_json::from_str::<HyperliquidFillDirection>("\"Some New Direction\"").unwrap(),
1275            HyperliquidFillDirection::Unknown,
1276        );
1277    }
1278
1279    #[rstest]
1280    fn test_position_type_unknown_is_lenient() {
1281        assert_eq!(
1282            serde_json::from_str::<HyperliquidPositionType>("\"hedge\"").unwrap(),
1283            HyperliquidPositionType::Unknown,
1284        );
1285    }
1286
1287    #[rstest]
1288    fn test_twap_status_unknown_is_lenient() {
1289        assert_eq!(
1290            serde_json::from_str::<HyperliquidTwapStatus>("\"paused\"").unwrap(),
1291            HyperliquidTwapStatus::Unknown,
1292        );
1293        assert_eq!(
1294            serde_json::from_str::<HyperliquidTwapStatus>("\"waitingForTrigger\"").unwrap(),
1295            HyperliquidTwapStatus::Unknown,
1296        );
1297    }
1298
1299    #[rstest]
1300    fn test_liquidity_flag_from_crossed() {
1301        assert_eq!(
1302            HyperliquidLiquidityFlag::from(true),
1303            HyperliquidLiquidityFlag::Taker
1304        );
1305        assert_eq!(
1306            HyperliquidLiquidityFlag::from(false),
1307            HyperliquidLiquidityFlag::Maker
1308        );
1309    }
1310
1311    #[rstest]
1312    #[allow(deprecated)]
1313    fn test_reject_code_from_error_string() {
1314        let test_cases = [
1315            (
1316                "Price must be divisible by tick size.",
1317                HyperliquidRejectCode::Tick,
1318            ),
1319            (
1320                "Order must have minimum value of $10.",
1321                HyperliquidRejectCode::MinTradeNtl,
1322            ),
1323            (
1324                "Insufficient margin to place order.",
1325                HyperliquidRejectCode::PerpMargin,
1326            ),
1327            (
1328                "Post only order would have immediately matched, bbo was 1.23",
1329                HyperliquidRejectCode::BadAloPx,
1330            ),
1331            (
1332                "Some unknown error",
1333                HyperliquidRejectCode::Unknown("Some unknown error".to_string()),
1334            ),
1335        ];
1336
1337        for (error_str, expected_code) in test_cases {
1338            assert_eq!(
1339                HyperliquidRejectCode::from_error_string(error_str),
1340                expected_code
1341            );
1342        }
1343    }
1344
1345    #[rstest]
1346    fn test_reject_code_from_api_error() {
1347        let test_cases = [
1348            (
1349                "Price must be divisible by tick size.",
1350                HyperliquidRejectCode::Tick,
1351            ),
1352            (
1353                "Order must have minimum value of $10.",
1354                HyperliquidRejectCode::MinTradeNtl,
1355            ),
1356            (
1357                "Insufficient margin to place order.",
1358                HyperliquidRejectCode::PerpMargin,
1359            ),
1360            (
1361                "Post only order would have immediately matched, bbo was 1.23",
1362                HyperliquidRejectCode::BadAloPx,
1363            ),
1364            (
1365                "Some unknown error",
1366                HyperliquidRejectCode::Unknown("Some unknown error".to_string()),
1367            ),
1368        ];
1369
1370        for (error_str, expected_code) in test_cases {
1371            assert_eq!(
1372                HyperliquidRejectCode::from_api_error(error_str),
1373                expected_code
1374            );
1375        }
1376    }
1377
1378    #[rstest]
1379    fn test_reduce_only() {
1380        let reduce_only = HyperliquidReduceOnly::new(true);
1381
1382        assert!(reduce_only.is_reduce_only());
1383
1384        let json = serde_json::to_string(&reduce_only).unwrap();
1385        assert_eq!(json, "true");
1386
1387        let parsed: HyperliquidReduceOnly = serde_json::from_str(&json).unwrap();
1388        assert_eq!(parsed, reduce_only);
1389    }
1390
1391    #[rstest]
1392    fn test_order_status_conversion() {
1393        // Test HyperliquidOrderStatus to OrderStatus conversion
1394        assert_eq!(
1395            OrderStatus::from(HyperliquidOrderStatus::Open),
1396            OrderStatus::Accepted
1397        );
1398        assert_eq!(
1399            OrderStatus::from(HyperliquidOrderStatus::Accepted),
1400            OrderStatus::Accepted
1401        );
1402        assert_eq!(
1403            OrderStatus::from(HyperliquidOrderStatus::Triggered),
1404            OrderStatus::Triggered
1405        );
1406        assert_eq!(
1407            OrderStatus::from(HyperliquidOrderStatus::Filled),
1408            OrderStatus::Filled
1409        );
1410        assert_eq!(
1411            OrderStatus::from(HyperliquidOrderStatus::Canceled),
1412            OrderStatus::Canceled
1413        );
1414        assert_eq!(
1415            OrderStatus::from(HyperliquidOrderStatus::Rejected),
1416            OrderStatus::Rejected
1417        );
1418
1419        // Test specific cancel reasons map to Canceled
1420        assert_eq!(
1421            OrderStatus::from(HyperliquidOrderStatus::MarginCanceled),
1422            OrderStatus::Canceled
1423        );
1424        assert_eq!(
1425            OrderStatus::from(HyperliquidOrderStatus::SelfTradeCanceled),
1426            OrderStatus::Canceled
1427        );
1428        assert_eq!(
1429            OrderStatus::from(HyperliquidOrderStatus::ReduceOnlyCanceled),
1430            OrderStatus::Canceled
1431        );
1432        assert_eq!(
1433            OrderStatus::from(HyperliquidOrderStatus::OutcomeSettledCanceled),
1434            OrderStatus::Canceled
1435        );
1436
1437        // Test specific reject reasons map to Rejected
1438        assert_eq!(
1439            OrderStatus::from(HyperliquidOrderStatus::TickRejected),
1440            OrderStatus::Rejected
1441        );
1442        assert_eq!(
1443            OrderStatus::from(HyperliquidOrderStatus::PerpMarginRejected),
1444            OrderStatus::Rejected
1445        );
1446    }
1447
1448    #[rstest]
1449    fn test_order_status_serde_deserialization() {
1450        // Test that camelCase status values deserialize correctly
1451        let open: HyperliquidOrderStatus = serde_json::from_str(r#""open""#).unwrap();
1452        assert_eq!(open, HyperliquidOrderStatus::Open);
1453
1454        let canceled: HyperliquidOrderStatus = serde_json::from_str(r#""canceled""#).unwrap();
1455        assert_eq!(canceled, HyperliquidOrderStatus::Canceled);
1456
1457        let margin_canceled: HyperliquidOrderStatus =
1458            serde_json::from_str(r#""marginCanceled""#).unwrap();
1459        assert_eq!(margin_canceled, HyperliquidOrderStatus::MarginCanceled);
1460
1461        let self_trade_canceled: HyperliquidOrderStatus =
1462            serde_json::from_str(r#""selfTradeCanceled""#).unwrap();
1463        assert_eq!(
1464            self_trade_canceled,
1465            HyperliquidOrderStatus::SelfTradeCanceled
1466        );
1467
1468        let reduce_only_canceled: HyperliquidOrderStatus =
1469            serde_json::from_str(r#""reduceOnlyCanceled""#).unwrap();
1470        assert_eq!(
1471            reduce_only_canceled,
1472            HyperliquidOrderStatus::ReduceOnlyCanceled
1473        );
1474
1475        let outcome_settled_canceled: HyperliquidOrderStatus =
1476            serde_json::from_str(r#""outcomeSettledCanceled""#).unwrap();
1477        assert_eq!(
1478            outcome_settled_canceled,
1479            HyperliquidOrderStatus::OutcomeSettledCanceled
1480        );
1481
1482        let tick_rejected: HyperliquidOrderStatus =
1483            serde_json::from_str(r#""tickRejected""#).unwrap();
1484        assert_eq!(tick_rejected, HyperliquidOrderStatus::TickRejected);
1485    }
1486
1487    #[rstest]
1488    fn test_hyperliquid_tpsl_serialization() {
1489        let tp = HyperliquidTpSl::Tp;
1490        let sl = HyperliquidTpSl::Sl;
1491
1492        assert_eq!(serde_json::to_string(&tp).unwrap(), r#""tp""#);
1493        assert_eq!(serde_json::to_string(&sl).unwrap(), r#""sl""#);
1494    }
1495
1496    #[rstest]
1497    fn test_hyperliquid_tpsl_deserialization() {
1498        let tp: HyperliquidTpSl = serde_json::from_str(r#""tp""#).unwrap();
1499        let sl: HyperliquidTpSl = serde_json::from_str(r#""sl""#).unwrap();
1500
1501        assert_eq!(tp, HyperliquidTpSl::Tp);
1502        assert_eq!(sl, HyperliquidTpSl::Sl);
1503    }
1504
1505    #[rstest]
1506    fn test_conditional_order_type_conversions() {
1507        // Test all conditional order types
1508        assert_eq!(
1509            OrderType::from(HyperliquidConditionalOrderType::StopMarket),
1510            OrderType::StopMarket
1511        );
1512        assert_eq!(
1513            OrderType::from(HyperliquidConditionalOrderType::StopLimit),
1514            OrderType::StopLimit
1515        );
1516        assert_eq!(
1517            OrderType::from(HyperliquidConditionalOrderType::TakeProfitMarket),
1518            OrderType::MarketIfTouched
1519        );
1520        assert_eq!(
1521            OrderType::from(HyperliquidConditionalOrderType::TakeProfitLimit),
1522            OrderType::LimitIfTouched
1523        );
1524        assert_eq!(
1525            OrderType::from(HyperliquidConditionalOrderType::TrailingStopMarket),
1526            OrderType::TrailingStopMarket
1527        );
1528    }
1529
1530    // Tests for error parsing with real and simulated error messages
1531    mod error_parsing_tests {
1532        use super::*;
1533
1534        #[rstest]
1535        fn test_parse_tick_size_error() {
1536            let error = "Price must be divisible by tick size 0.01";
1537            let code = HyperliquidRejectCode::from_api_error(error);
1538            assert_eq!(code, HyperliquidRejectCode::Tick);
1539        }
1540
1541        #[rstest]
1542        fn test_parse_tick_size_error_case_insensitive() {
1543            let error = "PRICE MUST BE DIVISIBLE BY TICK SIZE 0.01";
1544            let code = HyperliquidRejectCode::from_api_error(error);
1545            assert_eq!(code, HyperliquidRejectCode::Tick);
1546        }
1547
1548        #[rstest]
1549        fn test_parse_min_notional_perp() {
1550            let error = "Order must have minimum value of $10";
1551            let code = HyperliquidRejectCode::from_api_error(error);
1552            assert_eq!(code, HyperliquidRejectCode::MinTradeNtl);
1553        }
1554
1555        #[rstest]
1556        fn test_parse_min_notional_spot() {
1557            let error = "Order must have minimum value of 10 USDC";
1558            let code = HyperliquidRejectCode::from_api_error(error);
1559            assert_eq!(code, HyperliquidRejectCode::MinTradeSpotNtl);
1560        }
1561
1562        #[rstest]
1563        fn test_parse_insufficient_margin() {
1564            let error = "Insufficient margin to place order";
1565            let code = HyperliquidRejectCode::from_api_error(error);
1566            assert_eq!(code, HyperliquidRejectCode::PerpMargin);
1567        }
1568
1569        #[rstest]
1570        fn test_parse_insufficient_margin_case_variations() {
1571            let variations = vec![
1572                "insufficient margin to place order",
1573                "INSUFFICIENT MARGIN TO PLACE ORDER",
1574                "  Insufficient margin to place order  ", // with whitespace
1575            ];
1576
1577            for error in variations {
1578                let code = HyperliquidRejectCode::from_api_error(error);
1579                assert_eq!(code, HyperliquidRejectCode::PerpMargin);
1580            }
1581        }
1582
1583        #[rstest]
1584        fn test_parse_reduce_only_violation() {
1585            let error = "Reduce only order would increase position";
1586            let code = HyperliquidRejectCode::from_api_error(error);
1587            assert_eq!(code, HyperliquidRejectCode::ReduceOnly);
1588        }
1589
1590        #[rstest]
1591        fn test_parse_reduce_only_with_hyphen() {
1592            let error = "Reduce-only order would increase position";
1593            let code = HyperliquidRejectCode::from_api_error(error);
1594            assert_eq!(code, HyperliquidRejectCode::ReduceOnly);
1595        }
1596
1597        #[rstest]
1598        fn test_parse_post_only_match() {
1599            let error = "Post only order would have immediately matched";
1600            let code = HyperliquidRejectCode::from_api_error(error);
1601            assert_eq!(code, HyperliquidRejectCode::BadAloPx);
1602        }
1603
1604        #[rstest]
1605        fn test_parse_post_only_with_hyphen() {
1606            let error = "Post-only order would have immediately matched";
1607            let code = HyperliquidRejectCode::from_api_error(error);
1608            assert_eq!(code, HyperliquidRejectCode::BadAloPx);
1609        }
1610
1611        #[rstest]
1612        fn test_parse_ioc_no_match() {
1613            let error = "Order could not immediately match";
1614            let code = HyperliquidRejectCode::from_api_error(error);
1615            assert_eq!(code, HyperliquidRejectCode::IocCancel);
1616        }
1617
1618        #[rstest]
1619        fn test_parse_invalid_trigger_price() {
1620            let error = "Invalid TP/SL price";
1621            let code = HyperliquidRejectCode::from_api_error(error);
1622            assert_eq!(code, HyperliquidRejectCode::BadTriggerPx);
1623        }
1624
1625        #[rstest]
1626        fn test_parse_no_liquidity() {
1627            let error = "No liquidity available for market order";
1628            let code = HyperliquidRejectCode::from_api_error(error);
1629            assert_eq!(code, HyperliquidRejectCode::MarketOrderNoLiquidity);
1630        }
1631
1632        #[rstest]
1633        fn test_parse_position_increase_at_oi_cap() {
1634            let error = "PositionIncreaseAtOpenInterestCap";
1635            let code = HyperliquidRejectCode::from_api_error(error);
1636            assert_eq!(
1637                code,
1638                HyperliquidRejectCode::PositionIncreaseAtOpenInterestCap
1639            );
1640        }
1641
1642        #[rstest]
1643        fn test_parse_position_flip_at_oi_cap() {
1644            let error = "PositionFlipAtOpenInterestCap";
1645            let code = HyperliquidRejectCode::from_api_error(error);
1646            assert_eq!(code, HyperliquidRejectCode::PositionFlipAtOpenInterestCap);
1647        }
1648
1649        #[rstest]
1650        fn test_parse_too_aggressive_at_oi_cap() {
1651            let error = "TooAggressiveAtOpenInterestCap";
1652            let code = HyperliquidRejectCode::from_api_error(error);
1653            assert_eq!(code, HyperliquidRejectCode::TooAggressiveAtOpenInterestCap);
1654        }
1655
1656        #[rstest]
1657        fn test_parse_open_interest_increase() {
1658            let error = "OpenInterestIncrease";
1659            let code = HyperliquidRejectCode::from_api_error(error);
1660            assert_eq!(code, HyperliquidRejectCode::OpenInterestIncrease);
1661        }
1662
1663        #[rstest]
1664        fn test_parse_insufficient_spot_balance() {
1665            let error = "Insufficient spot balance";
1666            let code = HyperliquidRejectCode::from_api_error(error);
1667            assert_eq!(code, HyperliquidRejectCode::InsufficientSpotBalance);
1668        }
1669
1670        #[rstest]
1671        fn test_parse_oracle_error() {
1672            let error = "Oracle price unavailable";
1673            let code = HyperliquidRejectCode::from_api_error(error);
1674            assert_eq!(code, HyperliquidRejectCode::Oracle);
1675        }
1676
1677        #[rstest]
1678        fn test_parse_max_position() {
1679            let error = "Exceeds max position size";
1680            let code = HyperliquidRejectCode::from_api_error(error);
1681            assert_eq!(code, HyperliquidRejectCode::PerpMaxPosition);
1682        }
1683
1684        #[rstest]
1685        fn test_parse_missing_order() {
1686            let error = "MissingOrder";
1687            let code = HyperliquidRejectCode::from_api_error(error);
1688            assert_eq!(code, HyperliquidRejectCode::MissingOrder);
1689        }
1690
1691        #[rstest]
1692        fn test_parse_unknown_error() {
1693            let error = "This is a completely new error message";
1694            let code = HyperliquidRejectCode::from_api_error(error);
1695            assert!(matches!(code, HyperliquidRejectCode::Unknown(_)));
1696
1697            // Verify the original message is preserved
1698            if let HyperliquidRejectCode::Unknown(msg) = code {
1699                assert_eq!(msg, error);
1700            }
1701        }
1702
1703        #[rstest]
1704        fn test_parse_empty_error() {
1705            let error = "";
1706            let code = HyperliquidRejectCode::from_api_error(error);
1707            assert!(matches!(code, HyperliquidRejectCode::Unknown(_)));
1708        }
1709
1710        #[rstest]
1711        fn test_parse_whitespace_only() {
1712            let error = "   ";
1713            let code = HyperliquidRejectCode::from_api_error(error);
1714            assert!(matches!(code, HyperliquidRejectCode::Unknown(_)));
1715        }
1716
1717        #[rstest]
1718        fn test_normalization_preserves_original_in_unknown() {
1719            let error = "  UNKNOWN ERROR MESSAGE  ";
1720            let code = HyperliquidRejectCode::from_api_error(error);
1721
1722            // Should be Unknown, and should contain original message (not normalized)
1723            if let HyperliquidRejectCode::Unknown(msg) = code {
1724                assert_eq!(msg, error);
1725            } else {
1726                panic!("Expected Unknown variant");
1727            }
1728        }
1729    }
1730
1731    #[rstest]
1732    fn test_conditional_order_type_round_trip() {
1733        assert_eq!(
1734            OrderType::from(HyperliquidConditionalOrderType::TrailingStopLimit),
1735            OrderType::TrailingStopLimit
1736        );
1737
1738        // Test reverse conversions
1739        assert_eq!(
1740            HyperliquidConditionalOrderType::from(OrderType::StopMarket),
1741            HyperliquidConditionalOrderType::StopMarket
1742        );
1743        assert_eq!(
1744            HyperliquidConditionalOrderType::from(OrderType::StopLimit),
1745            HyperliquidConditionalOrderType::StopLimit
1746        );
1747    }
1748
1749    #[rstest]
1750    fn test_trailing_offset_type_serialization() {
1751        let price = HyperliquidTrailingOffsetType::Price;
1752        let percentage = HyperliquidTrailingOffsetType::Percentage;
1753        let basis_points = HyperliquidTrailingOffsetType::BasisPoints;
1754
1755        assert_eq!(serde_json::to_string(&price).unwrap(), r#""price""#);
1756        assert_eq!(
1757            serde_json::to_string(&percentage).unwrap(),
1758            r#""percentage""#
1759        );
1760        assert_eq!(
1761            serde_json::to_string(&basis_points).unwrap(),
1762            r#""basispoints""#
1763        );
1764    }
1765
1766    #[rstest]
1767    fn test_conditional_order_type_serialization() {
1768        assert_eq!(
1769            serde_json::to_string(&HyperliquidConditionalOrderType::StopMarket).unwrap(),
1770            r#""STOP_MARKET""#
1771        );
1772        assert_eq!(
1773            serde_json::to_string(&HyperliquidConditionalOrderType::StopLimit).unwrap(),
1774            r#""STOP_LIMIT""#
1775        );
1776        assert_eq!(
1777            serde_json::to_string(&HyperliquidConditionalOrderType::TakeProfitMarket).unwrap(),
1778            r#""TAKE_PROFIT_MARKET""#
1779        );
1780        assert_eq!(
1781            serde_json::to_string(&HyperliquidConditionalOrderType::TakeProfitLimit).unwrap(),
1782            r#""TAKE_PROFIT_LIMIT""#
1783        );
1784        assert_eq!(
1785            serde_json::to_string(&HyperliquidConditionalOrderType::TrailingStopMarket).unwrap(),
1786            r#""TRAILING_STOP_MARKET""#
1787        );
1788        assert_eq!(
1789            serde_json::to_string(&HyperliquidConditionalOrderType::TrailingStopLimit).unwrap(),
1790            r#""TRAILING_STOP_LIMIT""#
1791        );
1792    }
1793
1794    #[rstest]
1795    fn test_order_type_enum_coverage() {
1796        // Ensure all conditional order types roundtrip correctly
1797        let conditional_types = vec![
1798            HyperliquidConditionalOrderType::StopMarket,
1799            HyperliquidConditionalOrderType::StopLimit,
1800            HyperliquidConditionalOrderType::TakeProfitMarket,
1801            HyperliquidConditionalOrderType::TakeProfitLimit,
1802            HyperliquidConditionalOrderType::TrailingStopMarket,
1803            HyperliquidConditionalOrderType::TrailingStopLimit,
1804        ];
1805
1806        for cond_type in conditional_types {
1807            let order_type = OrderType::from(cond_type);
1808            let back_to_cond = HyperliquidConditionalOrderType::from(order_type);
1809            assert_eq!(cond_type, back_to_cond, "Roundtrip conversion failed");
1810        }
1811    }
1812
1813    #[rstest]
1814    #[case("BTC-USD-PERP", HyperliquidProductType::Perp)]
1815    #[case("HYPE-USDC-SPOT", HyperliquidProductType::Spot)]
1816    #[case("25-YES-OUTCOME", HyperliquidProductType::Outcome)]
1817    #[case("25-NO-OUTCOME", HyperliquidProductType::Outcome)]
1818    #[case("0-YES-OUTCOME", HyperliquidProductType::Outcome)]
1819    #[case("#10", HyperliquidProductType::Outcome)]
1820    #[case("+31", HyperliquidProductType::Outcome)]
1821    #[case("#0", HyperliquidProductType::Outcome)]
1822    fn test_product_type_from_symbol(
1823        #[case] symbol: &str,
1824        #[case] expected: HyperliquidProductType,
1825    ) {
1826        assert_eq!(
1827            HyperliquidProductType::from_symbol(symbol).unwrap(),
1828            expected
1829        );
1830    }
1831
1832    #[rstest]
1833    #[case("")]
1834    #[case("BTC")]
1835    #[case("#")]
1836    #[case("+")]
1837    #[case("#abc")]
1838    #[case("+12.5")]
1839    #[case("@1")]
1840    #[case("#-1")]
1841    #[case("+-1")]
1842    #[case("25-YES")]
1843    #[case("OUTCOME")]
1844    #[case("25-YES-outcome")]
1845    fn test_product_type_from_symbol_rejects_invalid(#[case] symbol: &str) {
1846        assert!(HyperliquidProductType::from_symbol(symbol).is_err());
1847    }
1848
1849    #[rstest]
1850    #[case("\"unifiedAccount\"", HyperliquidAccountAbstraction::UnifiedAccount)]
1851    #[case("\"portfolioMargin\"", HyperliquidAccountAbstraction::PortfolioMargin)]
1852    #[case("\"disabled\"", HyperliquidAccountAbstraction::Disabled)]
1853    #[case("\"default\"", HyperliquidAccountAbstraction::Default)]
1854    #[case("\"dexAbstraction\"", HyperliquidAccountAbstraction::DexAbstraction)]
1855    #[case("\"someFutureMode\"", HyperliquidAccountAbstraction::Unknown)]
1856    fn test_account_abstraction_deserialize(
1857        #[case] json: &str,
1858        #[case] expected: HyperliquidAccountAbstraction,
1859    ) {
1860        let parsed: HyperliquidAccountAbstraction = serde_json::from_str(json).unwrap();
1861        assert_eq!(parsed, expected);
1862    }
1863
1864    #[rstest]
1865    #[case(HyperliquidAccountAbstraction::UnifiedAccount, true)]
1866    #[case(HyperliquidAccountAbstraction::PortfolioMargin, true)]
1867    #[case(HyperliquidAccountAbstraction::Disabled, false)]
1868    #[case(HyperliquidAccountAbstraction::Default, false)]
1869    #[case(HyperliquidAccountAbstraction::DexAbstraction, false)]
1870    #[case(HyperliquidAccountAbstraction::Unknown, false)]
1871    fn test_account_abstraction_uses_spot_collateral(
1872        #[case] abstraction: HyperliquidAccountAbstraction,
1873        #[case] expected: bool,
1874    ) {
1875        assert_eq!(abstraction.uses_spot_collateral(), expected);
1876    }
1877}