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