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