Skip to main content

nautilus_hyperliquid/http/
models.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::{Debug, Display};
17
18use alloy_primitives::{Address, keccak256};
19#[cfg(test)]
20use nautilus_core::string::secret::REDACTED;
21use nautilus_core::{hex, string::secret::SecretString};
22use nautilus_model::identifiers::{ClientOrderId, VenueOrderId};
23use rust_decimal::Decimal;
24use serde::{Deserialize, Deserializer, Serialize, Serializer};
25use ustr::Ustr;
26
27use crate::common::{
28    enums::{
29        HyperliquidFillDirection, HyperliquidLeverageType,
30        HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidPositionType,
31        HyperliquidSide, HyperliquidTimeInForce,
32    },
33    parse::{
34        deserialize_decimal_from_str, deserialize_optional_decimal_from_str, is_false_or_none,
35        serialize_decimal_as_str, serialize_optional_decimal_as_str,
36    },
37};
38
39/// Response from candleSnapshot endpoint (returns array directly).
40pub type HyperliquidCandleSnapshot = Vec<HyperliquidCandle>;
41
42/// A 128-bit client order ID represented as a hex string with `0x` prefix.
43#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
44pub struct Cloid(pub [u8; 16]);
45
46impl Cloid {
47    /// Creates a new `Cloid` from a hex string.
48    ///
49    /// # Errors
50    ///
51    /// Returns an error if the string is not a valid 128-bit hex with `0x` prefix.
52    pub fn from_hex<S: AsRef<str>>(s: S) -> Result<Self, String> {
53        let hex_str = s.as_ref();
54        let without_prefix = hex_str
55            .strip_prefix("0x")
56            .ok_or("CLOID must start with '0x'")?;
57
58        if without_prefix.len() != 32 {
59            return Err("CLOID must be exactly 32 hex characters (128 bits)".to_string());
60        }
61
62        let bytes = hex::decode_array(without_prefix)
63            .map_err(|_| "Invalid hex character in CLOID".to_string())?;
64
65        Ok(Self(bytes))
66    }
67
68    /// Creates a deterministic `Cloid` from a Nautilus `ClientOrderId`.
69    #[must_use]
70    pub fn from_client_order_id(client_order_id: ClientOrderId) -> Self {
71        let hash = keccak256(client_order_id.as_str().as_bytes());
72        let mut bytes = [0u8; 16];
73        bytes.copy_from_slice(&hash[..16]);
74        Self(bytes)
75    }
76
77    /// Returns whether the CLOID matches the UUIDv4 version and variant bits.
78    #[must_use]
79    pub fn is_uuid_v4(&self) -> bool {
80        self.0[6] >> 4 == 4 && matches!(self.0[8] >> 4, 8..=11)
81    }
82
83    /// Converts the CLOID to a hex string with `0x` prefix.
84    pub fn to_hex(&self) -> String {
85        hex::encode_prefixed(self.0)
86    }
87}
88
89impl Display for Cloid {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        write!(f, "{}", self.to_hex())
92    }
93}
94
95impl Serialize for Cloid {
96    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
97    where
98        S: Serializer,
99    {
100        serializer.serialize_str(&self.to_hex())
101    }
102}
103
104impl<'de> Deserialize<'de> for Cloid {
105    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
106    where
107        D: Deserializer<'de>,
108    {
109        let s = String::deserialize(deserializer)?;
110        Self::from_hex(&s).map_err(serde::de::Error::custom)
111    }
112}
113
114/// Asset ID type for Hyperliquid.
115///
116/// For perpetuals, this is the index in `meta.universe`.
117/// For spot trading, this is `10000 + index` from `spotMeta.universe`.
118pub type AssetId = u32;
119
120/// Order ID assigned by Hyperliquid.
121pub type OrderId = u64;
122
123/// Represents asset information from the meta endpoint.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct HyperliquidAssetInfo {
127    /// Asset name (e.g., "BTC").
128    pub name: Ustr,
129    /// Number of decimal places for size.
130    pub sz_decimals: u32,
131    /// Maximum leverage allowed for this asset.
132    #[serde(default)]
133    pub max_leverage: Option<u32>,
134    /// Whether this asset requires isolated margin only.
135    #[serde(default)]
136    pub only_isolated: Option<bool>,
137    /// Whether this asset is delisted/inactive.
138    #[serde(default)]
139    pub is_delisted: Option<bool>,
140}
141
142/// Complete perpetuals metadata response from `POST /info` with `{ "type": "meta" }`.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct PerpMeta {
146    /// Perpetual assets universe.
147    pub universe: Vec<PerpAsset>,
148    /// Margin tables for leverage tiers.
149    #[serde(default)]
150    pub margin_tables: Vec<(u32, MarginTable)>,
151    /// Collateral token index for this perp dex. Missing on legacy `meta` responses.
152    #[serde(default)]
153    pub collateral_token: Option<u32>,
154}
155
156/// A single perpetual asset from the universe.
157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct PerpAsset {
160    /// Asset name (e.g., "BTC", "xyz:TSLA" for HIP-3).
161    pub name: String,
162    /// Number of decimal places for size.
163    pub sz_decimals: u32,
164    /// Maximum leverage allowed for this asset.
165    #[serde(default)]
166    pub max_leverage: Option<u32>,
167    /// Whether this asset requires isolated margin only.
168    #[serde(default)]
169    pub only_isolated: Option<bool>,
170    /// Whether this asset is delisted/inactive.
171    #[serde(default)]
172    pub is_delisted: Option<bool>,
173    /// HIP-3 growth mode status (e.g., "enabled").
174    #[serde(default)]
175    pub growth_mode: Option<String>,
176    /// Margin mode (e.g., "strictIsolated").
177    #[serde(default)]
178    pub margin_mode: Option<String>,
179}
180
181/// Margin table with leverage tiers.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct MarginTable {
185    /// Description of the margin table.
186    pub description: String,
187    /// Margin tiers for different position sizes.
188    #[serde(default)]
189    pub margin_tiers: Vec<MarginTier>,
190}
191
192/// Individual margin tier.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(rename_all = "camelCase")]
195pub struct MarginTier {
196    /// Lower bound for this tier.
197    #[serde(
198        serialize_with = "serialize_decimal_as_str",
199        deserialize_with = "deserialize_decimal_from_str"
200    )]
201    pub lower_bound: Decimal,
202    /// Maximum leverage for this tier.
203    pub max_leverage: u32,
204}
205
206/// Descriptor for a builder-deployed perp dex from `POST /info` with
207/// `{ "type": "perpDexs" }`.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct PerpDex {
211    /// Dex identifier used by WebSocket `dex` metadata and subscription routing.
212    pub name: String,
213}
214
215/// Complete spot metadata response from `POST /info` with `{ "type": "spotMeta" }`.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct SpotMeta {
219    /// Spot tokens available.
220    pub tokens: Vec<SpotToken>,
221    /// Spot pairs universe.
222    pub universe: Vec<SpotPair>,
223}
224
225/// EVM contract information for a spot token.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub struct EvmContract {
229    /// EVM contract address (20 bytes).
230    pub address: Address,
231    /// Extra wei decimals for EVM precision (can be negative).
232    pub evm_extra_wei_decimals: i32,
233}
234
235/// A single spot token from the tokens list.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(rename_all = "camelCase")]
238pub struct SpotToken {
239    /// Token name (e.g., "USDC").
240    pub name: String,
241    /// Number of decimal places for size.
242    pub sz_decimals: u32,
243    /// Wei decimals (on-chain precision).
244    pub wei_decimals: u32,
245    /// Token index used for pair references.
246    pub index: u32,
247    /// Token contract ID/address.
248    pub token_id: String,
249    /// Whether this is the canonical token.
250    pub is_canonical: bool,
251    /// Optional EVM contract information.
252    #[serde(default)]
253    pub evm_contract: Option<EvmContract>,
254    /// Optional full name.
255    #[serde(default)]
256    pub full_name: Option<String>,
257    /// Optional deployer trading fee share.
258    #[serde(default)]
259    pub deployer_trading_fee_share: Option<String>,
260}
261
262/// A single spot pair from the universe.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264#[serde(rename_all = "camelCase")]
265pub struct SpotPair {
266    /// Pair display name (e.g., "PURR/USDC").
267    pub name: String,
268    /// Token indices [base_token_index, quote_token_index].
269    pub tokens: [u32; 2],
270    /// Pair index.
271    pub index: u32,
272    /// Whether this is the canonical pair.
273    pub is_canonical: bool,
274}
275
276/// Complete outcome metadata response from `POST /info` with `{ "type": "outcomeMeta" }`.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct OutcomeMeta {
280    /// Outcome markets available.
281    pub outcomes: Vec<OutcomeMarket>,
282    /// Multi-outcome `priceBucket` questions that reference outcomes by
283    /// `named_outcomes` / `fallback_outcome`. Empty when the venue exposes
284    /// only standalone binary outcomes.
285    #[serde(default)]
286    pub questions: Vec<OutcomeQuestion>,
287}
288
289impl OutcomeMeta {
290    /// Returns the question that references the given outcome via
291    /// `fallback_outcome` or `named_outcomes`, if any.
292    #[must_use]
293    pub fn parent_question(&self, outcome_index: u32) -> Option<&OutcomeQuestion> {
294        self.questions.iter().find(|q| {
295            q.fallback_outcome == Some(outcome_index) || q.named_outcomes.contains(&outcome_index)
296        })
297    }
298}
299
300/// A single outcome market from the outcome metadata response.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(rename_all = "camelCase")]
303pub struct OutcomeMarket {
304    /// Outcome identifier used with side to derive HIP-4 asset IDs.
305    pub outcome: u32,
306    /// Outcome market name.
307    pub name: String,
308    /// Venue-provided market description.
309    pub description: String,
310    /// Quote and settlement token code when supplied by the venue.
311    #[serde(default)]
312    pub quote_token: Option<String>,
313    /// Side specifications for the binary outcome.
314    #[serde(default)]
315    pub side_specs: Vec<OutcomeSideSpec>,
316}
317
318/// A single side specification for an outcome market.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase")]
321pub struct OutcomeSideSpec {
322    /// Side name (for example, "Yes" or "No").
323    pub name: String,
324}
325
326/// A multi-outcome `priceBucket` question referenced by one or more outcomes.
327///
328/// Questions group a fallback outcome plus a sequence of named outcomes whose
329/// `description` field holds an `index:N` pointer back into `named_outcomes`.
330/// Settlement is signaled when `settled_named_outcomes` becomes non-empty.
331#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333pub struct OutcomeQuestion {
334    /// Question identifier.
335    pub question: u32,
336    /// Question name.
337    pub name: String,
338    /// Venue-provided question description (carries `class`, `expiry`, etc).
339    pub description: String,
340    /// Fallback outcome triggered when no named outcome resolves.
341    #[serde(default)]
342    pub fallback_outcome: Option<u32>,
343    /// Named outcome indices in the order their `index:N` descriptions reference.
344    #[serde(default)]
345    pub named_outcomes: Vec<u32>,
346    /// Outcomes that have settled. Non-empty implies the question has resolved.
347    #[serde(default)]
348    pub settled_named_outcomes: Vec<u32>,
349}
350
351/// Optional perpetuals metadata with asset contexts from `{ "type": "metaAndAssetCtxs" }`.
352/// Returns a tuple: `[PerpMeta, Vec<PerpAssetCtx>]`
353#[derive(Debug, Clone, Serialize, Deserialize)]
354#[serde(untagged)]
355pub enum PerpMetaAndCtxs {
356    /// Tuple format: [meta, contexts]
357    Payload(Box<(PerpMeta, Vec<PerpAssetCtx>)>),
358}
359
360/// Runtime context for a perpetual asset (mark prices, funding, etc).
361#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(rename_all = "camelCase")]
363pub struct PerpAssetCtx {
364    /// Mark price.
365    #[serde(
366        default,
367        serialize_with = "serialize_optional_decimal_as_str",
368        deserialize_with = "deserialize_optional_decimal_from_str"
369    )]
370    pub mark_px: Option<Decimal>,
371    /// Mid price.
372    #[serde(
373        default,
374        serialize_with = "serialize_optional_decimal_as_str",
375        deserialize_with = "deserialize_optional_decimal_from_str"
376    )]
377    pub mid_px: Option<Decimal>,
378    /// Funding rate.
379    #[serde(
380        default,
381        serialize_with = "serialize_optional_decimal_as_str",
382        deserialize_with = "deserialize_optional_decimal_from_str"
383    )]
384    pub funding: Option<Decimal>,
385    /// Open interest.
386    #[serde(
387        default,
388        serialize_with = "serialize_optional_decimal_as_str",
389        deserialize_with = "deserialize_optional_decimal_from_str"
390    )]
391    pub open_interest: Option<Decimal>,
392}
393
394/// Optional spot metadata with asset contexts from `{ "type": "spotMetaAndAssetCtxs" }`.
395/// Returns a tuple: `[SpotMeta, Vec<SpotAssetCtx>]`
396#[derive(Debug, Clone, Serialize, Deserialize)]
397#[serde(untagged)]
398pub enum SpotMetaAndCtxs {
399    /// Tuple format: [meta, contexts]
400    Payload(Box<(SpotMeta, Vec<SpotAssetCtx>)>),
401}
402
403/// Runtime context for a spot pair (prices, volumes, etc).
404#[derive(Debug, Clone, Serialize, Deserialize)]
405#[serde(rename_all = "camelCase")]
406pub struct SpotAssetCtx {
407    /// Mark price.
408    #[serde(
409        default,
410        serialize_with = "serialize_optional_decimal_as_str",
411        deserialize_with = "deserialize_optional_decimal_from_str"
412    )]
413    pub mark_px: Option<Decimal>,
414    /// Mid price.
415    #[serde(
416        default,
417        serialize_with = "serialize_optional_decimal_as_str",
418        deserialize_with = "deserialize_optional_decimal_from_str"
419    )]
420    pub mid_px: Option<Decimal>,
421    /// 24h volume.
422    #[serde(
423        default,
424        serialize_with = "serialize_optional_decimal_as_str",
425        deserialize_with = "deserialize_optional_decimal_from_str"
426    )]
427    pub day_volume: Option<Decimal>,
428}
429
430/// Represents an L2 order book snapshot from `POST /info`.
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct HyperliquidL2Book {
433    /// Coin symbol.
434    pub coin: Ustr,
435    /// Order book levels: [bids, asks].
436    pub levels: Vec<Vec<HyperliquidLevel>>,
437    /// Timestamp in milliseconds.
438    pub time: u64,
439}
440
441/// Represents an order book level with price and size.
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct HyperliquidLevel {
444    /// Price level.
445    #[serde(
446        serialize_with = "serialize_decimal_as_str",
447        deserialize_with = "deserialize_decimal_from_str"
448    )]
449    pub px: Decimal,
450    /// Size at this level.
451    #[serde(
452        serialize_with = "serialize_decimal_as_str",
453        deserialize_with = "deserialize_decimal_from_str"
454    )]
455    pub sz: Decimal,
456}
457
458/// Represents user fills response from `POST /info`.
459///
460/// The Hyperliquid API returns fills directly as an array, not wrapped in an object.
461pub type HyperliquidFills = Vec<HyperliquidFill>;
462
463/// Represents metadata about available markets from `POST /info`.
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct HyperliquidMeta {
466    #[serde(default)]
467    pub universe: Vec<HyperliquidAssetInfo>,
468}
469
470/// Represents a single candle (OHLCV bar) from Hyperliquid.
471#[derive(Debug, Clone, Serialize, Deserialize)]
472#[serde(rename_all = "camelCase")]
473pub struct HyperliquidCandle {
474    /// Candle start timestamp in milliseconds.
475    #[serde(rename = "t")]
476    pub timestamp: u64,
477    /// Candle end timestamp in milliseconds, inclusive.
478    #[serde(rename = "T")]
479    pub end_timestamp: u64,
480    /// Open price.
481    #[serde(
482        rename = "o",
483        serialize_with = "serialize_decimal_as_str",
484        deserialize_with = "deserialize_decimal_from_str"
485    )]
486    pub open: Decimal,
487    /// High price.
488    #[serde(
489        rename = "h",
490        serialize_with = "serialize_decimal_as_str",
491        deserialize_with = "deserialize_decimal_from_str"
492    )]
493    pub high: Decimal,
494    /// Low price.
495    #[serde(
496        rename = "l",
497        serialize_with = "serialize_decimal_as_str",
498        deserialize_with = "deserialize_decimal_from_str"
499    )]
500    pub low: Decimal,
501    /// Close price.
502    #[serde(
503        rename = "c",
504        serialize_with = "serialize_decimal_as_str",
505        deserialize_with = "deserialize_decimal_from_str"
506    )]
507    pub close: Decimal,
508    /// Volume.
509    #[serde(
510        rename = "v",
511        serialize_with = "serialize_decimal_as_str",
512        deserialize_with = "deserialize_decimal_from_str"
513    )]
514    pub volume: Decimal,
515    /// Number of trades (optional).
516    #[serde(rename = "n", default)]
517    pub num_trades: Option<u64>,
518}
519
520/// Represents a single funding history entry from the `fundingHistory` info endpoint.
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct HyperliquidFundingHistoryEntry {
523    /// Coin symbol (raw Hyperliquid name, e.g. `"BTC"`).
524    pub coin: Ustr,
525    /// Funding rate applied at the interval end.
526    #[serde(
527        rename = "fundingRate",
528        serialize_with = "serialize_decimal_as_str",
529        deserialize_with = "deserialize_decimal_from_str"
530    )]
531    pub funding_rate: Decimal,
532    /// Premium at the time of funding.
533    #[serde(
534        default,
535        serialize_with = "serialize_optional_decimal_as_str",
536        deserialize_with = "deserialize_optional_decimal_from_str"
537    )]
538    pub premium: Option<Decimal>,
539    /// Timestamp in milliseconds marking the end of the funding interval.
540    pub time: u64,
541}
542
543/// Represents a single trade from the `recentTrades` info endpoint.
544///
545/// The endpoint returns a recent snapshot of public trades (newest first) and
546/// shares the field layout of the `trades` WebSocket channel.
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct HyperliquidRecentTrade {
549    /// Coin symbol (raw Hyperliquid name, e.g. `"BTC"`).
550    pub coin: Ustr,
551    /// Aggressor side: `"A"` (ask/sell) or `"B"` (bid/buy).
552    pub side: HyperliquidSide,
553    /// Trade price.
554    #[serde(
555        serialize_with = "serialize_decimal_as_str",
556        deserialize_with = "deserialize_decimal_from_str"
557    )]
558    pub px: Decimal,
559    /// Trade size.
560    #[serde(
561        serialize_with = "serialize_decimal_as_str",
562        deserialize_with = "deserialize_decimal_from_str"
563    )]
564    pub sz: Decimal,
565    /// Hyperliquid trade hash.
566    pub hash: String,
567    /// Trade timestamp in milliseconds.
568    pub time: u64,
569    /// Venue trade identifier.
570    pub tid: u64,
571    /// Buyer and seller wallet addresses, in that order.
572    pub users: [String; 2],
573}
574
575/// Represents an individual fill from user fills.
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct HyperliquidFill {
578    /// Coin symbol.
579    pub coin: Ustr,
580    /// Fill price.
581    #[serde(
582        serialize_with = "serialize_decimal_as_str",
583        deserialize_with = "deserialize_decimal_from_str"
584    )]
585    pub px: Decimal,
586    /// Fill size.
587    #[serde(
588        serialize_with = "serialize_decimal_as_str",
589        deserialize_with = "deserialize_decimal_from_str"
590    )]
591    pub sz: Decimal,
592    /// Order side (buy/sell).
593    pub side: HyperliquidSide,
594    /// Fill timestamp in milliseconds.
595    pub time: u64,
596    /// Position size before this fill.
597    #[serde(
598        rename = "startPosition",
599        serialize_with = "serialize_decimal_as_str",
600        deserialize_with = "deserialize_decimal_from_str"
601    )]
602    pub start_position: Decimal,
603    /// Fill direction (open/close).
604    pub dir: HyperliquidFillDirection,
605    /// Closed P&L from this fill.
606    #[serde(
607        rename = "closedPnl",
608        serialize_with = "serialize_decimal_as_str",
609        deserialize_with = "deserialize_decimal_from_str"
610    )]
611    pub closed_pnl: Decimal,
612    /// Hash reference.
613    pub hash: String,
614    /// Order ID that generated this fill.
615    pub oid: u64,
616    /// Crossed status.
617    pub crossed: bool,
618    /// Fee paid for this fill.
619    #[serde(
620        serialize_with = "serialize_decimal_as_str",
621        deserialize_with = "deserialize_decimal_from_str"
622    )]
623    pub fee: Decimal,
624    /// Official venue trade identifier from `userFills`.
625    #[serde(default)]
626    pub tid: u64,
627    /// Token the fee was paid in (e.g. "USDC", "HYPE").
628    #[serde(rename = "feeToken")]
629    pub fee_token: Ustr,
630    /// Optional builder fee reported by the venue.
631    #[serde(
632        rename = "builderFee",
633        default,
634        skip_serializing_if = "Option::is_none",
635        serialize_with = "serialize_optional_decimal_as_str",
636        deserialize_with = "deserialize_optional_decimal_from_str"
637    )]
638    pub builder_fee: Option<Decimal>,
639    /// Optional client order ID (hex representation of the venue CLOID).
640    #[serde(default)]
641    pub cloid: Option<String>,
642}
643
644/// Represents order status response from `POST /info` with `type: "orderStatus"`.
645///
646/// The API returns `{"status": "order", "order": {...}}` when the order is known,
647/// or `{"status": "unknownOid"}` when the oid is not found.
648#[derive(Debug, Clone, Serialize, Deserialize)]
649#[serde(tag = "status", rename_all = "camelCase")]
650pub enum HyperliquidOrderStatus {
651    Order { order: HyperliquidOrderStatusEntry },
652    UnknownOid,
653}
654
655impl HyperliquidOrderStatus {
656    /// Consumes the response and returns the inner entry if the order was found.
657    #[must_use]
658    pub fn into_order(self) -> Option<HyperliquidOrderStatusEntry> {
659        match self {
660            Self::Order { order } => Some(order),
661            Self::UnknownOid => None,
662        }
663    }
664}
665
666/// Represents an individual order status entry.
667#[derive(Debug, Clone, Serialize, Deserialize)]
668pub struct HyperliquidOrderStatusEntry {
669    /// Order information.
670    pub order: HyperliquidOrderInfo,
671    /// Current status.
672    pub status: HyperliquidOrderStatusEnum,
673    /// Status timestamp in milliseconds.
674    #[serde(rename = "statusTimestamp")]
675    pub status_timestamp: u64,
676}
677
678/// Represents order information within an order status entry.
679#[derive(Debug, Clone, Serialize, Deserialize)]
680pub struct HyperliquidOrderInfo {
681    /// Coin symbol.
682    pub coin: Ustr,
683    /// Order side (buy/sell).
684    pub side: HyperliquidSide,
685    /// Limit price.
686    #[serde(
687        rename = "limitPx",
688        serialize_with = "serialize_decimal_as_str",
689        deserialize_with = "deserialize_decimal_from_str"
690    )]
691    pub limit_px: Decimal,
692    /// Order size.
693    #[serde(
694        serialize_with = "serialize_decimal_as_str",
695        deserialize_with = "deserialize_decimal_from_str"
696    )]
697    pub sz: Decimal,
698    /// Order ID.
699    pub oid: u64,
700    /// Order timestamp in milliseconds.
701    pub timestamp: u64,
702    /// Original order size.
703    #[serde(
704        rename = "origSz",
705        serialize_with = "serialize_decimal_as_str",
706        deserialize_with = "deserialize_decimal_from_str"
707    )]
708    pub orig_sz: Decimal,
709    /// Optional client order ID (hex representation of the venue CLOID).
710    #[serde(default)]
711    pub cloid: Option<String>,
712    /// Time in force used by the order.
713    #[serde(default)]
714    pub tif: Option<HyperliquidTimeInForce>,
715    /// Whether the order reduces an existing position.
716    #[serde(rename = "reduceOnly", default)]
717    pub reduce_only: Option<bool>,
718    /// Trigger price for conditional orders.
719    #[serde(
720        rename = "triggerPx",
721        default,
722        deserialize_with = "deserialize_optional_decimal_from_str"
723    )]
724    pub trigger_px: Option<Decimal>,
725    /// Venue order type label.
726    #[serde(rename = "orderType", default)]
727    pub order_type: Option<String>,
728}
729
730/// ECC signature components for Hyperliquid exchange requests.
731#[derive(Debug, Clone, Serialize)]
732pub struct HyperliquidSignature {
733    /// R component of the signature.
734    pub r: SecretString,
735    /// S component of the signature.
736    pub s: SecretString,
737    /// V component (recovery ID) of the signature.
738    pub v: u64,
739}
740
741impl HyperliquidSignature {
742    /// Creates a new [`HyperliquidSignature`] from pre-formatted components.
743    #[must_use]
744    pub fn new(r: impl Into<SecretString>, s: impl Into<SecretString>, v: u64) -> Self {
745        Self {
746            r: r.into(),
747            s: s.into(),
748            v,
749        }
750    }
751
752    /// Formats as Ethereum hex signature: `0x` + r(64) + s(64) + v(2).
753    #[must_use]
754    pub fn to_hex(&self) -> SecretString {
755        let r = self
756            .r
757            .expose_secret()
758            .strip_prefix("0x")
759            .unwrap_or(self.r.expose_secret());
760        let s = self
761            .s
762            .expose_secret()
763            .strip_prefix("0x")
764            .unwrap_or(self.s.expose_secret());
765        SecretString::from(format!("0x{r}{s}{:02x}", self.v))
766    }
767
768    /// Parses a hex signature string (0x + 64 hex r + 64 hex s + 2 hex v) into components.
769    pub fn from_hex(sig_hex: &str) -> Result<Self, String> {
770        let sig_hex = sig_hex.strip_prefix("0x").unwrap_or(sig_hex);
771
772        if sig_hex.len() != 130 {
773            return Err(format!(
774                "Invalid signature length: expected 130 hex chars, was {}",
775                sig_hex.len()
776            ));
777        }
778
779        let r = format!("0x{}", &sig_hex[0..64]);
780        let s = format!("0x{}", &sig_hex[64..128]);
781        let v = u64::from_str_radix(&sig_hex[128..130], 16)
782            .map_err(|e| format!("Failed to parse v component: {e}"))?;
783
784        Ok(Self::new(r, s, v))
785    }
786}
787
788/// Represents an exchange action request wrapper for `POST /exchange`.
789#[derive(Debug, Clone, Serialize)]
790pub struct HyperliquidExchangeRequest<T> {
791    /// The action to perform.
792    #[serde(rename = "action")]
793    pub action: T,
794    /// Request nonce for replay protection.
795    #[serde(rename = "nonce")]
796    pub nonce: u64,
797    /// ECC signature over the action.
798    #[serde(rename = "signature")]
799    pub signature: HyperliquidSignature,
800    /// Optional vault address for sub-account trading.
801    #[serde(rename = "vaultAddress", skip_serializing_if = "Option::is_none")]
802    pub vault_address: Option<String>,
803    /// Optional expiration time in milliseconds.
804    #[serde(rename = "expiresAfter", skip_serializing_if = "Option::is_none")]
805    pub expires_after: Option<u64>,
806}
807
808impl<T> HyperliquidExchangeRequest<T>
809where
810    T: Serialize,
811{
812    /// Creates a new exchange request with the given action.
813    #[must_use]
814    pub fn new(action: T, nonce: u64, signature: HyperliquidSignature) -> Self {
815        Self {
816            action,
817            nonce,
818            signature,
819            vault_address: None,
820            expires_after: None,
821        }
822    }
823
824    /// Creates a new exchange request with vault address for sub-account trading.
825    #[must_use]
826    pub fn with_vault(
827        action: T,
828        nonce: u64,
829        signature: HyperliquidSignature,
830        vault_address: String,
831    ) -> Self {
832        Self {
833            action,
834            nonce,
835            signature,
836            vault_address: Some(vault_address),
837            expires_after: None,
838        }
839    }
840
841    /// Convert to JSON value for signing purposes.
842    pub fn to_sign_value(&self) -> serde_json::Result<serde_json::Value> {
843        serde_json::to_value(self)
844    }
845}
846
847/// Represents an exchange response wrapper from `POST /exchange`.
848#[derive(Debug, Clone, Serialize, Deserialize)]
849#[serde(untagged)]
850pub enum HyperliquidExchangeResponse {
851    /// Successful response with status.
852    Status {
853        /// Status message.
854        status: String,
855        /// Response payload.
856        response: serde_json::Value,
857    },
858    /// Error response.
859    Error {
860        /// Error message.
861        error: String,
862    },
863}
864
865impl HyperliquidExchangeResponse {
866    pub fn is_ok(&self) -> bool {
867        matches!(self, Self::Status { status, .. } if status == RESPONSE_STATUS_OK)
868    }
869}
870
871/// The success status string returned by the Hyperliquid exchange API.
872pub const RESPONSE_STATUS_OK: &str = "ok";
873
874#[cfg(test)]
875mod tests {
876    use rstest::rstest;
877    use rust_decimal_macros::dec;
878    use serde_json::json;
879
880    use super::*;
881    use crate::http::query::ExchangeAction;
882
883    #[rstest]
884    #[case(0)]
885    #[case(1)]
886    #[case(2)]
887    #[case(3)]
888    fn test_leverage_margin_wire_contract(#[case] case_index: usize) {
889        let fixture: serde_json::Value =
890            serde_json::from_str(include_str!("../../test_data/leverage_margin_actions.json"))
891                .unwrap();
892        let case = &fixture["cases"][case_index];
893        let expected_json = case["json"].as_str().unwrap();
894        let expected_bytes = hex::decode(case["msgpack"].as_str().unwrap()).unwrap();
895        let action: HyperliquidExchangeAction = serde_json::from_str(expected_json).unwrap();
896
897        let legacy = match action {
898            HyperliquidExchangeAction::UpdateLeverage {
899                asset,
900                is_cross,
901                leverage,
902            } => ExchangeAction::update_leverage(asset, is_cross, leverage),
903            HyperliquidExchangeAction::UpdateIsolatedMargin {
904                asset,
905                is_buy,
906                ntli,
907            } => ExchangeAction::update_isolated_margin(asset, is_buy, ntli),
908            _ => panic!("unexpected fixture action"),
909        };
910
911        let bytes = rmp_serde::to_vec_named(&action).unwrap();
912        let roundtrip: HyperliquidExchangeAction = rmp_serde::from_slice(&bytes).unwrap();
913
914        assert_eq!(serde_json::to_string(&action).unwrap(), expected_json);
915        assert_eq!(serde_json::to_string(&legacy).unwrap(), expected_json);
916        assert_eq!(bytes, expected_bytes);
917        assert_eq!(rmp_serde::to_vec_named(&legacy).unwrap(), expected_bytes);
918        assert_eq!(roundtrip, action);
919    }
920
921    #[rstest]
922    fn test_signature_serialization_and_debug_redaction() {
923        let signature = HyperliquidSignature::new(
924            "0x1111111111111111111111111111111111111111111111111111111111111111".to_string(),
925            "0x2222222222222222222222222222222222222222222222222222222222222222".to_string(),
926            27,
927        );
928        let wire = serde_json::to_value(&signature).unwrap();
929        let debug = format!("{signature:?}");
930
931        assert_eq!(wire["r"], signature.r.expose_secret());
932        assert_eq!(wire["s"], signature.s.expose_secret());
933        assert_eq!(wire["v"], signature.v);
934        assert_eq!(debug.matches(REDACTED).count(), 2);
935        assert!(!debug.contains(signature.r.expose_secret()));
936        assert!(!debug.contains(signature.s.expose_secret()));
937    }
938
939    #[rstest]
940    fn test_exchange_action_request_debug_redacts_signature() {
941        let request = HyperliquidExchangeActionRequest {
942            action: HyperliquidExchangeAction::Noop,
943            nonce: 1_700_000_000_000,
944            signature: SecretString::from("0xsigned-action"),
945            vault_address: Some("0xvault".to_string()),
946            expires_after: Some(1_700_000_001_000),
947        };
948        let wire = serde_json::to_value(&request).unwrap();
949        let debug = format!("{request:?}");
950
951        assert_eq!(wire["signature"], "0xsigned-action");
952        assert_eq!(wire["nonce"], 1_700_000_000_000_u64);
953        assert!(debug.contains(REDACTED));
954        assert!(!debug.contains("0xsigned-action"));
955    }
956
957    #[rstest]
958    fn test_meta_deserialization() {
959        let json = r#"{"universe": [{"name": "BTC", "szDecimals": 5}]}"#;
960
961        let meta: HyperliquidMeta = serde_json::from_str(json).unwrap();
962
963        assert_eq!(meta.universe.len(), 1);
964        assert_eq!(meta.universe[0].name, "BTC");
965        assert_eq!(meta.universe[0].sz_decimals, 5);
966    }
967
968    #[rstest]
969    fn test_funding_history_entry_with_premium() {
970        let json = r#"{
971            "coin": "BTC",
972            "fundingRate": "0.0000125",
973            "premium": "0.00029005",
974            "time": 1769908800000
975        }"#;
976
977        let entry: HyperliquidFundingHistoryEntry = serde_json::from_str(json).unwrap();
978
979        assert_eq!(entry.coin, "BTC");
980        assert_eq!(entry.funding_rate, dec!(0.0000125));
981        assert_eq!(entry.premium, Some(dec!(0.00029005)));
982        assert_eq!(entry.time, 1769908800000);
983    }
984
985    #[rstest]
986    fn test_funding_history_entry_without_premium() {
987        // `premium` is optional in the venue response; it must deserialize
988        // to `None` when absent rather than fail.
989        let json = r#"{
990            "coin": "BTC",
991            "fundingRate": "0.0000033",
992            "time": 1769916000000
993        }"#;
994
995        let entry: HyperliquidFundingHistoryEntry = serde_json::from_str(json).unwrap();
996
997        assert!(entry.premium.is_none());
998        assert_eq!(entry.funding_rate, dec!(0.0000033));
999    }
1000
1001    #[rstest]
1002    fn test_recent_trade_deserializes() {
1003        // The venue payload carries `hash`/`users` fields the model ignores.
1004        let json = r#"{
1005            "coin": "BTC",
1006            "side": "B",
1007            "px": "104250.0",
1008            "sz": "0.0123",
1009            "hash": "0xabc",
1010            "time": 1769916000000,
1011            "tid": 987654321,
1012            "users": ["0xbuyer", "0xseller"]
1013        }"#;
1014
1015        let trade: HyperliquidRecentTrade = serde_json::from_str(json).unwrap();
1016
1017        assert_eq!(trade.coin, "BTC");
1018        assert_eq!(trade.side, HyperliquidSide::Buy);
1019        assert_eq!(trade.px, dec!(104250.0));
1020        assert_eq!(trade.sz, dec!(0.0123));
1021        assert_eq!(trade.time, 1769916000000);
1022        assert_eq!(trade.tid, 987654321);
1023    }
1024
1025    #[rstest]
1026    fn test_order_status_deserializes_frontend_market_tif() {
1027        let status: HyperliquidOrderStatus =
1028            crate::common::testing::load_test_data("http_order_status_frontend_market.json");
1029        let entry = status.into_order().expect("order status entry");
1030
1031        assert_eq!(entry.order.oid, 1);
1032        assert_eq!(
1033            entry.order.tif,
1034            Some(HyperliquidTimeInForce::FrontendMarket)
1035        );
1036        assert_eq!(entry.status, HyperliquidOrderStatusEnum::Filled);
1037    }
1038
1039    #[rstest]
1040    fn test_historical_order_deserializes_liquidation_market_tif() {
1041        let entry: HyperliquidOrderStatusEntry =
1042            crate::common::testing::load_test_data("http_historical_order_liquidation_market.json");
1043
1044        assert_eq!(entry.order.oid, 42);
1045        assert_eq!(
1046            entry.order.tif,
1047            Some(HyperliquidTimeInForce::LiquidationMarket)
1048        );
1049        assert_eq!(entry.status, HyperliquidOrderStatusEnum::Filled);
1050    }
1051
1052    #[rstest]
1053    fn test_user_fill_deserializes_tid_and_builder_fee() {
1054        let json = r#"{
1055            "coin": "BTC",
1056            "px": "60000.5",
1057            "sz": "0.001",
1058            "side": "B",
1059            "time": 1704470400000,
1060            "startPosition": "0",
1061            "dir": "Open Long",
1062            "closedPnl": "1.25",
1063            "hash": "0xabc",
1064            "oid": 7001,
1065            "crossed": true,
1066            "fee": "0.02",
1067            "feeToken": "USDC",
1068            "tid": 9001,
1069            "builderFee": "0.001"
1070        }"#;
1071
1072        let fill: HyperliquidFill = serde_json::from_str(json).unwrap();
1073
1074        assert_eq!(fill.coin, "BTC");
1075        assert_eq!(fill.oid, 7001);
1076        assert_eq!(fill.tid, 9001);
1077        assert_eq!(fill.builder_fee, Some(dec!(0.001)));
1078        assert_eq!(fill.fee, dec!(0.02));
1079    }
1080
1081    #[rstest]
1082    fn test_user_fill_defaults_missing_tid_and_builder_fee() {
1083        let json = r#"{
1084            "coin": "ETH",
1085            "px": "2500.25",
1086            "sz": "0.5",
1087            "side": "A",
1088            "time": 1704470401000,
1089            "startPosition": "1.0",
1090            "dir": "Close Long",
1091            "closedPnl": "2.5",
1092            "hash": "0xdef",
1093            "oid": 8002,
1094            "crossed": false,
1095            "fee": "0.01",
1096            "feeToken": "USDC"
1097        }"#;
1098
1099        let fill: HyperliquidFill = serde_json::from_str(json).unwrap();
1100
1101        assert_eq!(fill.oid, 8002);
1102        assert_eq!(fill.tid, 0);
1103        assert_eq!(fill.builder_fee, None);
1104        assert_eq!(fill.fee, dec!(0.01));
1105        assert!(!fill.crossed);
1106    }
1107
1108    #[rstest]
1109    fn test_perp_asset_hip3_fields() {
1110        let json = r#"{
1111            "name": "xyz:TSLA",
1112            "szDecimals": 3,
1113            "maxLeverage": 10,
1114            "onlyIsolated": true,
1115            "growthMode": "enabled",
1116            "marginMode": "strictIsolated"
1117        }"#;
1118
1119        let asset: PerpAsset = serde_json::from_str(json).unwrap();
1120
1121        assert_eq!(asset.name, "xyz:TSLA");
1122        assert_eq!(asset.sz_decimals, 3);
1123        assert_eq!(asset.max_leverage, Some(10));
1124        assert_eq!(asset.only_isolated, Some(true));
1125        assert_eq!(asset.growth_mode.as_deref(), Some("enabled"));
1126        assert_eq!(asset.margin_mode.as_deref(), Some("strictIsolated"));
1127    }
1128
1129    #[rstest]
1130    fn test_perp_asset_hip3_fields_absent() {
1131        let json = r#"{"name": "BTC", "szDecimals": 5}"#;
1132
1133        let asset: PerpAsset = serde_json::from_str(json).unwrap();
1134
1135        assert_eq!(asset.growth_mode, None);
1136        assert_eq!(asset.margin_mode, None);
1137    }
1138
1139    #[rstest]
1140    fn test_outcome_meta_defaults_missing_side_specs() {
1141        let json = r#"{
1142            "outcomes": [
1143                {
1144                    "outcome": 123,
1145                    "name": "Recurring",
1146                    "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m"
1147                }
1148            ]
1149        }"#;
1150
1151        let meta: OutcomeMeta = serde_json::from_str(json).unwrap();
1152
1153        assert_eq!(meta.outcomes.len(), 1);
1154        assert_eq!(meta.outcomes[0].outcome, 123);
1155        assert!(meta.outcomes[0].side_specs.is_empty());
1156    }
1157
1158    #[rstest]
1159    fn test_l2_book_deserialization() {
1160        let json = r#"{"coin": "BTC", "levels": [[{"px": "50000", "sz": "1.5"}], [{"px": "50100", "sz": "2.0"}]], "time": 1234567890}"#;
1161
1162        let book: HyperliquidL2Book = serde_json::from_str(json).unwrap();
1163
1164        assert_eq!(book.coin, "BTC");
1165        assert_eq!(book.levels.len(), 2);
1166        assert_eq!(book.time, 1234567890);
1167    }
1168
1169    #[rstest]
1170    fn test_exchange_response_deserialization() {
1171        let json = r#"{"status": "ok", "response": {"type": "order"}}"#;
1172
1173        let response: HyperliquidExchangeResponse = serde_json::from_str(json).unwrap();
1174        assert!(response.is_ok());
1175    }
1176
1177    #[rstest]
1178    fn test_spot_clearinghouse_state_deserialization() {
1179        let json = r#"{
1180            "balances": [
1181                {"coin": "USDC", "token": 0, "total": "14.625485", "hold": "0.0", "entryNtl": "0.0"},
1182                {"coin": "PURR", "token": 1, "total": "2000", "hold": "100", "entryNtl": "1234.56"}
1183            ]
1184        }"#;
1185
1186        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
1187
1188        assert_eq!(state.balances.len(), 2);
1189        let usdc = &state.balances[0];
1190        assert_eq!(usdc.coin, "USDC");
1191        assert_eq!(usdc.token, Some(0));
1192        assert_eq!(usdc.total.to_string(), "14.625485");
1193        assert_eq!(usdc.hold, rust_decimal::Decimal::ZERO);
1194        assert_eq!(usdc.free().to_string(), "14.625485");
1195        assert_eq!(usdc.avg_entry_px(), None);
1196
1197        let purr = &state.balances[1];
1198        assert_eq!(purr.coin, "PURR");
1199        assert_eq!(purr.token, Some(1));
1200        assert_eq!(purr.free().to_string(), "1900");
1201        assert_eq!(
1202            purr.avg_entry_px().unwrap(),
1203            rust_decimal_macros::dec!(0.61728)
1204        );
1205    }
1206
1207    #[rstest]
1208    fn test_spot_balance_outcome_side_token_lacks_token_field() {
1209        // HIP-4 outcome side tokens come back without `token` from the venue
1210        let json = r#"{"coin": "+250", "total": "0.0", "hold": "0.0", "entryNtl": "0.0"}"#;
1211        let balance: SpotBalance = serde_json::from_str(json).unwrap();
1212        assert_eq!(balance.coin, "+250");
1213        assert_eq!(balance.token, None);
1214    }
1215
1216    #[rstest]
1217    fn test_spot_clearinghouse_state_empty() {
1218        let json = r#"{"balances": []}"#;
1219        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
1220        assert!(state.balances.is_empty());
1221    }
1222
1223    #[rstest]
1224    fn test_spot_balance_handles_missing_entry_ntl() {
1225        let json = r#"{"coin": "HYPE", "token": 150, "total": "5", "hold": "0"}"#;
1226        let balance: SpotBalance = serde_json::from_str(json).unwrap();
1227        assert_eq!(balance.entry_ntl, None);
1228        assert_eq!(balance.avg_entry_px(), None);
1229    }
1230
1231    #[rstest]
1232    fn test_msgpack_serialization_matches_python() {
1233        // Test that msgpack serialization includes the "type" tag properly.
1234        // Python SDK serializes: {"type": "order", "orders": [...], "grouping": "na"}
1235        // We need to verify rmp_serde::to_vec_named produces the same format.
1236
1237        let action = HyperliquidExchangeAction::Order {
1238            orders: vec![],
1239            grouping: HyperliquidExchangeGrouping::Na,
1240            builder: None,
1241        };
1242
1243        // First verify JSON is correct
1244        let json = serde_json::to_string(&action).unwrap();
1245        assert!(
1246            json.contains(r#""type":"order""#),
1247            "JSON should have type tag: {json}"
1248        );
1249
1250        // Serialize with msgpack
1251        let msgpack_bytes = rmp_serde::to_vec_named(&action).unwrap();
1252
1253        // Decode back to a generic Value to inspect the structure
1254        let decoded: serde_json::Value = rmp_serde::from_slice(&msgpack_bytes).unwrap();
1255
1256        // The decoded value should have a "type" field
1257        assert!(
1258            decoded.get("type").is_some(),
1259            "MsgPack should have type tag. Decoded: {decoded:?}"
1260        );
1261        assert_eq!(
1262            decoded.get("type").unwrap().as_str().unwrap(),
1263            "order",
1264            "Type should be 'order'"
1265        );
1266        assert!(decoded.get("orders").is_some(), "Should have orders field");
1267        assert!(
1268            decoded.get("grouping").is_some(),
1269            "Should have grouping field"
1270        );
1271    }
1272
1273    #[rstest]
1274    fn test_cancel_action_serialization(
1275        #[values(None, Some(false), Some(true))] fast: Option<bool>,
1276        #[values(false, true)] by_cloid: bool,
1277    ) {
1278        let (action, name) = if by_cloid {
1279            (
1280                HyperliquidExchangeAction::CancelByCloid {
1281                    cancels: vec![HyperliquidExchangeCancelByCloidRequest {
1282                        asset: 7,
1283                        cloid: Cloid::from_hex("0x0123456789abcdef0123456789abcdef").unwrap(),
1284                    }],
1285                    fast,
1286                },
1287                "cancel_by_cloid",
1288            )
1289        } else {
1290            (
1291                HyperliquidExchangeAction::Cancel {
1292                    cancels: vec![HyperliquidExchangeCancelOrderRequest {
1293                        asset: 7,
1294                        oid: 12345,
1295                    }],
1296                    fast,
1297                },
1298                "cancel",
1299            )
1300        };
1301
1302        let fixtures: serde_json::Value =
1303            serde_json::from_str(include_str!("../../test_data/cancel_actions.json")).unwrap();
1304        let expected = &fixtures[name][if fast == Some(true) { "fast" } else { "normal" }];
1305        let json = serde_json::to_value(&action).unwrap();
1306        let msgpack = rmp_serde::to_vec_named(&action).unwrap();
1307
1308        assert_eq!(json, expected["json"]);
1309        assert_eq!(hex::encode(msgpack), expected["msgpack"].as_str().unwrap());
1310    }
1311
1312    #[rstest]
1313    fn test_order_response_normal_tpsl_with_waiting_children() {
1314        // `normalTpsl` bracket: the entry rests with an oid, while the SL/TP
1315        // children come back as bare strings until the parent fills or the
1316        // trigger fires.
1317        let json = r#"{
1318            "statuses": [
1319                {"resting": {"oid": 446050656712}},
1320                "waitingForFill",
1321                "waitingForTrigger"
1322            ]
1323        }"#;
1324
1325        let data: HyperliquidExchangeOrderResponseData = serde_json::from_str(json).unwrap();
1326        assert_eq!(data.statuses.len(), 3);
1327
1328        assert!(matches!(
1329            data.statuses[0],
1330            HyperliquidExchangeOrderStatus::Resting { ref resting } if resting.oid == 446050656712
1331        ));
1332        assert!(matches!(
1333            data.statuses[1],
1334            HyperliquidExchangeOrderStatus::Tag(HyperliquidExchangeOrderStatusTag::WaitingForFill)
1335        ));
1336        assert!(matches!(
1337            data.statuses[2],
1338            HyperliquidExchangeOrderStatus::Tag(
1339                HyperliquidExchangeOrderStatusTag::WaitingForTrigger
1340            )
1341        ));
1342    }
1343
1344    #[rstest]
1345    fn test_user_outcome_split_serialization() {
1346        let action = HyperliquidExchangeAction::UserOutcome {
1347            op: HyperliquidExchangeUserOutcomeOp::SplitOutcome(
1348                HyperliquidExchangeSplitOutcomeParams {
1349                    outcome: 1,
1350                    amount: dec!(123.0),
1351                },
1352            ),
1353        };
1354
1355        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1356        assert_eq!(
1357            value,
1358            json!({
1359                "type": "userOutcome",
1360                "splitOutcome": { "outcome": 1, "amount": "123.0" }
1361            })
1362        );
1363    }
1364
1365    #[rstest]
1366    fn test_user_outcome_split_msgpack_roundtrip() {
1367        let action = HyperliquidExchangeAction::UserOutcome {
1368            op: HyperliquidExchangeUserOutcomeOp::SplitOutcome(
1369                HyperliquidExchangeSplitOutcomeParams {
1370                    outcome: 4,
1371                    amount: dec!(10),
1372                },
1373            ),
1374        };
1375
1376        let bytes = rmp_serde::to_vec_named(&action).unwrap();
1377        let decoded: serde_json::Value = rmp_serde::from_slice(&bytes).unwrap();
1378        assert_eq!(
1379            decoded,
1380            json!({
1381                "type": "userOutcome",
1382                "splitOutcome": { "outcome": 4, "amount": "10" }
1383            })
1384        );
1385    }
1386
1387    #[rstest]
1388    fn test_hyperliquid_level_serializes_decimals_as_strings() {
1389        // Decimal fields must serialize back to the string wire form, not a
1390        // JSON number.
1391        let level = HyperliquidLevel {
1392            px: dec!(98450.5),
1393            sz: dec!(2.5),
1394        };
1395        let value = serde_json::to_value(&level).unwrap();
1396        assert_eq!(value, json!({ "px": "98450.5", "sz": "2.5" }));
1397    }
1398
1399    #[rstest]
1400    fn test_user_outcome_merge_outcome_serialization() {
1401        let action = HyperliquidExchangeAction::UserOutcome {
1402            op: HyperliquidExchangeUserOutcomeOp::MergeOutcome(
1403                HyperliquidExchangeMergeOutcomeParams {
1404                    outcome: 1,
1405                    amount: Some(dec!(5.0)),
1406                },
1407            ),
1408        };
1409        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1410        assert_eq!(
1411            value,
1412            json!({
1413                "type": "userOutcome",
1414                "mergeOutcome": { "outcome": 1, "amount": "5.0" }
1415            })
1416        );
1417    }
1418
1419    #[rstest]
1420    fn test_user_outcome_merge_outcome_null_amount_means_max() {
1421        let action = HyperliquidExchangeAction::UserOutcome {
1422            op: HyperliquidExchangeUserOutcomeOp::MergeOutcome(
1423                HyperliquidExchangeMergeOutcomeParams {
1424                    outcome: 7,
1425                    amount: None,
1426                },
1427            ),
1428        };
1429        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1430        assert_eq!(
1431            value,
1432            json!({
1433                "type": "userOutcome",
1434                "mergeOutcome": { "outcome": 7, "amount": null }
1435            })
1436        );
1437    }
1438
1439    #[rstest]
1440    fn test_user_outcome_merge_question_serialization() {
1441        let action = HyperliquidExchangeAction::UserOutcome {
1442            op: HyperliquidExchangeUserOutcomeOp::MergeQuestion(
1443                HyperliquidExchangeMergeQuestionParams {
1444                    question: 9,
1445                    amount: Some(dec!(2.0)),
1446                },
1447            ),
1448        };
1449        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1450        assert_eq!(
1451            value,
1452            json!({
1453                "type": "userOutcome",
1454                "mergeQuestion": { "question": 9, "amount": "2.0" }
1455            })
1456        );
1457    }
1458
1459    #[rstest]
1460    fn test_user_outcome_merge_question_null_amount_means_max() {
1461        let action = HyperliquidExchangeAction::UserOutcome {
1462            op: HyperliquidExchangeUserOutcomeOp::MergeQuestion(
1463                HyperliquidExchangeMergeQuestionParams {
1464                    question: 9,
1465                    amount: None,
1466                },
1467            ),
1468        };
1469        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1470        assert_eq!(
1471            value,
1472            json!({
1473                "type": "userOutcome",
1474                "mergeQuestion": { "question": 9, "amount": null }
1475            })
1476        );
1477    }
1478
1479    #[rstest]
1480    fn test_user_outcome_negate_outcome_serialization() {
1481        let action = HyperliquidExchangeAction::UserOutcome {
1482            op: HyperliquidExchangeUserOutcomeOp::NegateOutcome(
1483                HyperliquidExchangeNegateOutcomeParams {
1484                    question: 9,
1485                    outcome: 52,
1486                    amount: dec!(1.5),
1487                },
1488            ),
1489        };
1490        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1491        assert_eq!(
1492            value,
1493            json!({
1494                "type": "userOutcome",
1495                "negateOutcome": { "question": 9, "outcome": 52, "amount": "1.5" }
1496            })
1497        );
1498    }
1499
1500    #[rstest]
1501    fn test_modify_target_serializes_numeric_oid() {
1502        let request = modify_request_with_target(HyperliquidExchangeModifyTarget::Oid(12345));
1503        let value: serde_json::Value = serde_json::to_value(request).unwrap();
1504
1505        assert_eq!(value["oid"], json!(12345));
1506    }
1507
1508    #[rstest]
1509    fn test_modify_target_serializes_cloid() {
1510        let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
1511        let request = modify_request_with_target(HyperliquidExchangeModifyTarget::Cloid(cloid));
1512        let value: serde_json::Value = serde_json::to_value(request).unwrap();
1513
1514        assert_eq!(value["oid"], json!("0x1234567890abcdef1234567890abcdef"));
1515    }
1516
1517    #[rstest]
1518    #[case("51000.00", "0.2500", "52000.000", "51000", "0.25", "52000")]
1519    #[case("0.00", "0.0000", "-0.000", "0", "0", "0")]
1520    #[case("-12.3400", "2.500", "-10.0100", "-12.34", "2.5", "-10.01")]
1521    #[case(
1522        "0.0000000000000000000000000001",
1523        "1.000",
1524        "10.0100",
1525        "0.0000000000000000000000000001",
1526        "1",
1527        "10.01"
1528    )]
1529    #[case(
1530        "79228162514264337593543950335",
1531        "0.2500",
1532        "52000.00",
1533        "79228162514264337593543950335",
1534        "0.25",
1535        "52000"
1536    )]
1537    fn test_order_decimals_serialize_without_trailing_zeros(
1538        #[case] price: &str,
1539        #[case] size: &str,
1540        #[case] trigger_price: &str,
1541        #[case] expected_price: &str,
1542        #[case] expected_size: &str,
1543        #[case] expected_trigger_price: &str,
1544        #[values(false, true)] is_market: bool,
1545    ) {
1546        let request = HyperliquidExchangePlaceOrderRequest {
1547            asset: 7,
1548            is_buy: false,
1549            price: price.parse().unwrap(),
1550            size: size.parse().unwrap(),
1551            reduce_only: true,
1552            kind: HyperliquidExchangeOrderKind::Trigger {
1553                trigger: HyperliquidExchangeTriggerParams {
1554                    is_market,
1555                    trigger_px: trigger_price.parse().unwrap(),
1556                    tpsl: HyperliquidExchangeTpSl::Tp,
1557                },
1558            },
1559            cloid: Some(Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap()),
1560        };
1561
1562        let expected = json!({
1563            "a": 7, "b": false, "p": expected_price, "s": expected_size, "r": true,
1564            "t": {"trigger": {"isMarket": is_market, "triggerPx": expected_trigger_price, "tpsl": "tp"}},
1565            "c": "0x1234567890abcdef1234567890abcdef",
1566        });
1567        let canonical: HyperliquidExchangePlaceOrderRequest =
1568            serde_json::from_value(expected.clone()).unwrap();
1569        let json = serde_json::to_value(&request).unwrap();
1570        let msgpack = rmp_serde::to_vec_named(&request).unwrap();
1571        let decoded: serde_json::Value = rmp_serde::from_slice(&msgpack).unwrap();
1572
1573        assert_eq!(json, expected);
1574        assert_eq!(decoded, expected);
1575        assert_eq!(msgpack, rmp_serde::to_vec_named(&canonical).unwrap());
1576        assert_eq!(
1577            serde_json::to_vec(&request).unwrap(),
1578            serde_json::to_vec(&canonical).unwrap()
1579        );
1580        assert_eq!(request.price.to_string(), price);
1581        assert_eq!(request.size.to_string(), size);
1582        assert_eq!(canonical, request);
1583    }
1584
1585    fn modify_request_with_target(
1586        oid: HyperliquidExchangeModifyTarget,
1587    ) -> HyperliquidExchangeModifyOrderRequest {
1588        HyperliquidExchangeModifyOrderRequest {
1589            oid,
1590            order: HyperliquidExchangePlaceOrderRequest {
1591                asset: 0,
1592                is_buy: true,
1593                price: dec!(51000),
1594                size: dec!(0.2),
1595                reduce_only: false,
1596                kind: HyperliquidExchangeOrderKind::Limit {
1597                    limit: HyperliquidExchangeLimitParams {
1598                        tif: HyperliquidExchangeTif::Gtc,
1599                    },
1600                },
1601                cloid: None,
1602            },
1603        }
1604    }
1605}
1606
1607/// Time-in-force for limit orders in exchange endpoint.
1608///
1609/// These values must match exactly what Hyperliquid expects for proper serialization.
1610#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1611pub enum HyperliquidExchangeTif {
1612    /// Add Liquidity Only (post-only order).
1613    #[serde(rename = "Alo")]
1614    Alo,
1615    /// Immediate or Cancel.
1616    #[serde(rename = "Ioc")]
1617    Ioc,
1618    /// Good Till Canceled.
1619    #[serde(rename = "Gtc")]
1620    Gtc,
1621}
1622
1623/// Take profit or stop loss side for trigger orders in exchange endpoint.
1624#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1625pub enum HyperliquidExchangeTpSl {
1626    /// Take profit.
1627    #[serde(rename = "tp")]
1628    Tp,
1629    /// Stop loss.
1630    #[serde(rename = "sl")]
1631    Sl,
1632}
1633
1634/// Order grouping strategy for linked TP/SL orders in exchange endpoint.
1635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1636pub enum HyperliquidExchangeGrouping {
1637    /// No grouping semantics.
1638    #[serde(rename = "na")]
1639    #[default]
1640    Na,
1641    /// Normal TP/SL grouping (linked orders).
1642    #[serde(rename = "normalTpsl")]
1643    NormalTpsl,
1644    /// Position-level TP/SL grouping.
1645    #[serde(rename = "positionTpsl")]
1646    PositionTpsl,
1647}
1648
1649/// Order kind specification for the `t` field in exchange endpoint order requests.
1650#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1651#[serde(untagged)]
1652pub enum HyperliquidExchangeOrderKind {
1653    /// Limit order with time-in-force.
1654    Limit {
1655        /// Limit order parameters.
1656        limit: HyperliquidExchangeLimitParams,
1657    },
1658    /// Trigger order (stop/take profit).
1659    Trigger {
1660        /// Trigger order parameters.
1661        trigger: HyperliquidExchangeTriggerParams,
1662    },
1663}
1664
1665/// Parameters for limit orders in exchange endpoint.
1666#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1667pub struct HyperliquidExchangeLimitParams {
1668    /// Time-in-force for the limit order.
1669    pub tif: HyperliquidExchangeTif,
1670}
1671
1672/// Parameters for trigger orders (stop/take profit) in exchange endpoint.
1673#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1674#[serde(rename_all = "camelCase")]
1675pub struct HyperliquidExchangeTriggerParams {
1676    /// Whether to use market price when triggered.
1677    pub is_market: bool,
1678    /// Trigger price as a string with no trailing zeros.
1679    #[serde(
1680        serialize_with = "serialize_order_decimal_as_str",
1681        deserialize_with = "deserialize_decimal_from_str"
1682    )]
1683    pub trigger_px: Decimal,
1684    /// Whether this is a take profit or stop loss.
1685    pub tpsl: HyperliquidExchangeTpSl,
1686}
1687
1688/// Builder code for order attribution in the exchange endpoint.
1689///
1690/// The fee is specified in tenths of a basis point.
1691/// For example, `f: 10` represents 1 basis point (0.01%).
1692#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1693pub struct HyperliquidExchangeBuilderFee {
1694    /// Builder address for attribution.
1695    #[serde(rename = "b")]
1696    pub address: String,
1697    /// Fee in tenths of a basis point.
1698    #[serde(rename = "f")]
1699    pub fee_tenths_bp: u32,
1700}
1701
1702/// Order specification for placing orders via exchange endpoint.
1703///
1704/// This struct represents a single order in the exact format expected
1705/// by the Hyperliquid exchange endpoint.
1706#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1707pub struct HyperliquidExchangePlaceOrderRequest {
1708    /// Asset ID.
1709    #[serde(rename = "a")]
1710    pub asset: AssetId,
1711    /// Is buy order (true for buy, false for sell).
1712    #[serde(rename = "b")]
1713    pub is_buy: bool,
1714    /// Price as a string with no trailing zeros.
1715    #[serde(
1716        rename = "p",
1717        serialize_with = "serialize_order_decimal_as_str",
1718        deserialize_with = "deserialize_decimal_from_str"
1719    )]
1720    pub price: Decimal,
1721    /// Size as a string with no trailing zeros.
1722    #[serde(
1723        rename = "s",
1724        serialize_with = "serialize_order_decimal_as_str",
1725        deserialize_with = "deserialize_decimal_from_str"
1726    )]
1727    pub size: Decimal,
1728    /// Reduce-only flag.
1729    #[serde(rename = "r")]
1730    pub reduce_only: bool,
1731    /// Order type (limit or trigger).
1732    #[serde(rename = "t")]
1733    pub kind: HyperliquidExchangeOrderKind,
1734    /// Optional client order ID (128-bit hex).
1735    #[serde(rename = "c", skip_serializing_if = "Option::is_none")]
1736    pub cloid: Option<Cloid>,
1737}
1738
1739/// Cancel specification for canceling orders by order ID via exchange endpoint.
1740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1741pub struct HyperliquidExchangeCancelOrderRequest {
1742    /// Asset ID.
1743    #[serde(rename = "a")]
1744    pub asset: AssetId,
1745    /// Order ID to cancel.
1746    #[serde(rename = "o")]
1747    pub oid: OrderId,
1748}
1749
1750/// Cancel specification for canceling orders by client order ID via exchange endpoint.
1751///
1752/// Note: Unlike order placement which uses abbreviated field names ("a", "c"),
1753/// cancel-by-cloid uses full field names ("asset", "cloid") per the Hyperliquid API.
1754#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1755pub struct HyperliquidExchangeCancelByCloidRequest {
1756    /// Asset ID.
1757    pub asset: AssetId,
1758    /// Client order ID to cancel.
1759    pub cloid: Cloid,
1760}
1761
1762/// Target of a modify request.
1763///
1764/// Hyperliquid names this field `oid`, but accepts either a numeric venue
1765/// order ID or a CLOID.
1766#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1767#[serde(untagged)]
1768pub enum HyperliquidExchangeModifyTarget {
1769    /// Numeric venue order ID.
1770    Oid(OrderId),
1771    /// CLOID.
1772    Cloid(Cloid),
1773}
1774
1775impl HyperliquidExchangeModifyTarget {
1776    /// Creates a numeric modify target from a Nautilus venue order ID.
1777    ///
1778    /// # Errors
1779    ///
1780    /// Returns an error if the venue order ID is not a numeric Hyperliquid order ID.
1781    pub fn from_venue_order_id(
1782        venue_order_id: &VenueOrderId,
1783    ) -> Result<Self, std::num::ParseIntError> {
1784        venue_order_id.as_str().parse::<OrderId>().map(Self::Oid)
1785    }
1786}
1787
1788impl From<OrderId> for HyperliquidExchangeModifyTarget {
1789    fn from(value: OrderId) -> Self {
1790        Self::Oid(value)
1791    }
1792}
1793
1794impl From<Cloid> for HyperliquidExchangeModifyTarget {
1795    fn from(value: Cloid) -> Self {
1796        Self::Cloid(value)
1797    }
1798}
1799
1800/// Modify specification for modifying existing orders via exchange endpoint.
1801///
1802/// The HL API requires the full order spec (same as a place order) plus
1803/// the venue order ID or CLOID to modify.
1804#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1805pub struct HyperliquidExchangeModifyOrderRequest {
1806    /// Venue order ID or CLOID to modify.
1807    pub oid: HyperliquidExchangeModifyTarget,
1808    /// Full replacement order specification.
1809    pub order: HyperliquidExchangePlaceOrderRequest,
1810}
1811
1812/// Parameters for the HIP-4 `splitOutcome` operation inside a `userOutcome` action.
1813///
1814/// Debits `amount` quote tokens from the user's spot balance and credits both
1815/// the Yes and No side tokens of the referenced outcome.
1816#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1817pub struct HyperliquidExchangeSplitOutcomeParams {
1818    /// Outcome index (matches `outcomeMeta.outcomes[i].outcome`).
1819    pub outcome: u32,
1820    /// Quote-token amount to split, serialized as a decimal string (e.g. `"123.0"`).
1821    #[serde(
1822        serialize_with = "serialize_decimal_as_str",
1823        deserialize_with = "deserialize_decimal_from_str"
1824    )]
1825    pub amount: Decimal,
1826}
1827
1828/// Parameters for the HIP-4 `mergeOutcome` operation inside a `userOutcome` action.
1829///
1830/// Burns `amount` matched Yes + No side tokens of `outcome` for `amount` quote
1831/// tokens back. `amount = None` serializes as `null`, which the venue treats as
1832/// the maximum mergeable balance.
1833#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1834pub struct HyperliquidExchangeMergeOutcomeParams {
1835    /// Outcome index whose Yes + No pair is being merged.
1836    pub outcome: u32,
1837    /// Side-token amount to merge, or `None` to merge the maximum available.
1838    #[serde(
1839        default,
1840        serialize_with = "serialize_optional_decimal_as_str",
1841        deserialize_with = "deserialize_optional_decimal_from_str"
1842    )]
1843    pub amount: Option<Decimal>,
1844}
1845
1846/// Parameters for the HIP-4 `mergeQuestion` operation inside a `userOutcome` action.
1847///
1848/// Burns `amount` Yes shares of every outcome associated with `question` for
1849/// `amount` quote tokens back. `amount = None` serializes as `null`, meaning
1850/// the maximum mergeable balance.
1851#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1852pub struct HyperliquidExchangeMergeQuestionParams {
1853    /// Question identifier whose named outcomes are being merged.
1854    pub question: u32,
1855    /// Yes-share amount to merge per outcome, or `None` for the max.
1856    #[serde(
1857        default,
1858        serialize_with = "serialize_optional_decimal_as_str",
1859        deserialize_with = "deserialize_optional_decimal_from_str"
1860    )]
1861    pub amount: Option<Decimal>,
1862}
1863
1864/// Parameters for the HIP-4 `negateOutcome` operation inside a `userOutcome` action.
1865///
1866/// Converts `amount` `No` shares of `outcome` (within `question`) into `amount`
1867/// `Yes` shares of every other outcome in the same question.
1868#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1869pub struct HyperliquidExchangeNegateOutcomeParams {
1870    /// Question identifier the outcome belongs to.
1871    pub question: u32,
1872    /// Outcome index whose `No` shares are being negated.
1873    pub outcome: u32,
1874    /// Side-token amount to negate, serialized as a decimal string.
1875    #[serde(
1876        serialize_with = "serialize_decimal_as_str",
1877        deserialize_with = "deserialize_decimal_from_str"
1878    )]
1879    pub amount: Decimal,
1880}
1881
1882/// Operations carried by the [`HyperliquidExchangeAction::UserOutcome`] action.
1883///
1884/// Each variant serializes as a single-keyed object (for example,
1885/// `{ "splitOutcome": { ... } }`) and is flattened into the outer action
1886/// envelope alongside `"type": "userOutcome"` to match the Hyperliquid wire
1887/// format.
1888#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1889pub enum HyperliquidExchangeUserOutcomeOp {
1890    /// Split `amount` quote tokens into `amount` Yes plus `amount` No shares.
1891    #[serde(rename = "splitOutcome")]
1892    SplitOutcome(HyperliquidExchangeSplitOutcomeParams),
1893    /// Merge `amount` Yes + No side-token pairs of `outcome` back into quote
1894    /// tokens (reverse of [`Self::SplitOutcome`]).
1895    #[serde(rename = "mergeOutcome")]
1896    MergeOutcome(HyperliquidExchangeMergeOutcomeParams),
1897    /// Merge `amount` Yes shares of every outcome in `question` into quote
1898    /// tokens (multi-outcome reverse of `splitOutcome`).
1899    #[serde(rename = "mergeQuestion")]
1900    MergeQuestion(HyperliquidExchangeMergeQuestionParams),
1901    /// Swap `amount` `No` shares of one outcome into `Yes` shares of every
1902    /// other outcome in the same question.
1903    #[serde(rename = "negateOutcome")]
1904    NegateOutcome(HyperliquidExchangeNegateOutcomeParams),
1905}
1906
1907/// TWAP (Time-Weighted Average Price) order specification for exchange endpoint.
1908#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1909pub struct HyperliquidExchangeTwapRequest {
1910    /// Asset ID.
1911    #[serde(rename = "a")]
1912    pub asset: AssetId,
1913    /// Is buy order.
1914    #[serde(rename = "b")]
1915    pub is_buy: bool,
1916    /// Total size to execute.
1917    #[serde(
1918        rename = "s",
1919        serialize_with = "serialize_decimal_as_str",
1920        deserialize_with = "deserialize_decimal_from_str"
1921    )]
1922    pub size: Decimal,
1923    /// Duration in milliseconds.
1924    #[serde(rename = "m")]
1925    pub duration_ms: u64,
1926}
1927
1928/// All possible exchange actions for the Hyperliquid `/exchange` endpoint.
1929///
1930/// Each variant corresponds to a specific action type that can be performed
1931/// through the exchange API. The serialization uses the exact action type
1932/// names expected by Hyperliquid.
1933#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1934#[serde(tag = "type")]
1935pub enum HyperliquidExchangeAction {
1936    /// Place one or more orders.
1937    #[serde(rename = "order")]
1938    Order {
1939        /// List of orders to place.
1940        orders: Vec<HyperliquidExchangePlaceOrderRequest>,
1941        /// Grouping strategy for TP/SL orders.
1942        #[serde(default)]
1943        grouping: HyperliquidExchangeGrouping,
1944        /// Optional builder code for attribution.
1945        #[serde(skip_serializing_if = "Option::is_none")]
1946        builder: Option<HyperliquidExchangeBuilderFee>,
1947    },
1948
1949    /// Cancel orders by order ID.
1950    #[serde(rename = "cancel")]
1951    Cancel {
1952        /// Orders to cancel.
1953        cancels: Vec<HyperliquidExchangeCancelOrderRequest>,
1954        /// Optional fast-cancel flag, serialized only when true.
1955        #[serde(rename = "f", skip_serializing_if = "is_false_or_none")]
1956        fast: Option<bool>,
1957    },
1958
1959    /// Cancel orders by client order ID.
1960    #[serde(rename = "cancelByCloid")]
1961    CancelByCloid {
1962        /// Orders to cancel by CLOID.
1963        cancels: Vec<HyperliquidExchangeCancelByCloidRequest>,
1964        /// Optional fast-cancel flag, serialized only when true.
1965        #[serde(rename = "f", skip_serializing_if = "is_false_or_none")]
1966        fast: Option<bool>,
1967    },
1968
1969    /// Modify a single order.
1970    #[serde(rename = "modify")]
1971    Modify {
1972        /// Order modification specification.
1973        #[serde(flatten)]
1974        modify: HyperliquidExchangeModifyOrderRequest,
1975    },
1976
1977    /// Modify multiple orders in one action.
1978    #[serde(rename = "batchModify")]
1979    BatchModify {
1980        /// Multiple order modifications.
1981        modifies: Vec<HyperliquidExchangeModifyOrderRequest>,
1982    },
1983
1984    /// Schedule automatic order cancellation (dead man's switch).
1985    #[serde(rename = "scheduleCancel")]
1986    ScheduleCancel {
1987        /// Time in milliseconds when orders should be cancelled.
1988        /// If None, clears the existing schedule.
1989        #[serde(skip_serializing_if = "Option::is_none")]
1990        time: Option<u64>,
1991    },
1992
1993    /// Update leverage for a position.
1994    #[serde(rename = "updateLeverage")]
1995    UpdateLeverage {
1996        /// Asset ID.
1997        asset: AssetId,
1998        /// Whether to use cross margin.
1999        #[serde(rename = "isCross")]
2000        is_cross: bool,
2001        /// Leverage value.
2002        #[serde(rename = "leverage")]
2003        leverage: u32,
2004    },
2005
2006    /// Update isolated margin for a position.
2007    #[serde(rename = "updateIsolatedMargin")]
2008    UpdateIsolatedMargin {
2009        /// Asset ID.
2010        asset: AssetId,
2011        /// Whether to target the buy side; has no effect until hedge mode is introduced.
2012        #[serde(rename = "isBuy")]
2013        is_buy: bool,
2014        /// Margin change in millionths of USDC, negative to remove margin.
2015        ntli: i64,
2016    },
2017
2018    /// Transfer USD between spot and perp accounts.
2019    ///
2020    /// Unsupported by the typed signing and posting methods.
2021    #[serde(rename = "usdClassTransfer")]
2022    UsdClassTransfer {
2023        /// Source account type.
2024        from: String,
2025        /// Destination account type.
2026        to: String,
2027        /// Amount to transfer.
2028        #[serde(
2029            serialize_with = "serialize_decimal_as_str",
2030            deserialize_with = "deserialize_decimal_from_str"
2031        )]
2032        amount: Decimal,
2033    },
2034
2035    /// HIP-4 outcome-side token management (`splitOutcome` and related ops).
2036    ///
2037    /// The active op is carried via [`HyperliquidExchangeUserOutcomeOp`] and
2038    /// flattened into this action envelope, producing wire payloads such as
2039    /// `{ "type": "userOutcome", "splitOutcome": { ... } }`.
2040    #[serde(rename = "userOutcome")]
2041    UserOutcome {
2042        /// Operation to perform on the user's outcome balances.
2043        #[serde(flatten)]
2044        op: HyperliquidExchangeUserOutcomeOp,
2045    },
2046
2047    /// Place a TWAP order.
2048    #[serde(rename = "twapPlace")]
2049    TwapPlace {
2050        /// TWAP order specification.
2051        #[serde(flatten)]
2052        twap: HyperliquidExchangeTwapRequest,
2053    },
2054
2055    /// Cancel a TWAP order.
2056    #[serde(rename = "twapCancel")]
2057    TwapCancel {
2058        /// Asset ID.
2059        #[serde(rename = "a")]
2060        asset: AssetId,
2061        /// TWAP ID.
2062        #[serde(rename = "t")]
2063        twap_id: u64,
2064    },
2065
2066    /// No-operation to invalidate pending nonces.
2067    #[serde(rename = "noop")]
2068    Noop,
2069}
2070
2071/// Typed exchange action request envelope for the `/exchange` endpoint.
2072///
2073/// This is the top-level structure sent to Hyperliquid's exchange endpoint.
2074/// It includes the action to perform along with authentication and metadata.
2075#[derive(Debug, Clone, Serialize)]
2076#[serde(rename_all = "camelCase")]
2077pub struct HyperliquidExchangeActionRequest {
2078    /// The exchange action to perform.
2079    pub action: HyperliquidExchangeAction,
2080    /// Request nonce for replay protection (milliseconds timestamp recommended).
2081    pub nonce: u64,
2082    /// ECC signature over the action and nonce.
2083    pub signature: SecretString,
2084    /// Optional vault address for sub-account trading.
2085    #[serde(skip_serializing_if = "Option::is_none")]
2086    pub vault_address: Option<String>,
2087    /// Optional expiration time in milliseconds.
2088    /// Note: Using this field increases rate limit weight by 5x if the request expires.
2089    #[serde(skip_serializing_if = "Option::is_none")]
2090    pub expires_after: Option<u64>,
2091}
2092
2093/// Typed exchange action response envelope from the `/exchange` endpoint.
2094#[derive(Debug, Clone, Serialize, Deserialize)]
2095pub struct HyperliquidExchangeActionResponse {
2096    /// Response status ("ok" for success).
2097    pub status: String,
2098    /// Response payload.
2099    pub response: HyperliquidExchangeResponseData,
2100}
2101
2102/// Response data containing the actual response payload from exchange endpoint.
2103#[derive(Debug, Clone, Serialize, Deserialize)]
2104#[serde(tag = "type")]
2105pub enum HyperliquidExchangeResponseData {
2106    /// Response for order actions.
2107    #[serde(rename = "order")]
2108    Order {
2109        /// Order response data.
2110        data: HyperliquidExchangeOrderResponseData,
2111    },
2112    /// Response for cancel actions.
2113    #[serde(rename = "cancel")]
2114    Cancel {
2115        /// Cancel response data.
2116        data: HyperliquidExchangeCancelResponseData,
2117    },
2118    /// Response for modify actions.
2119    #[serde(rename = "modify")]
2120    Modify {
2121        /// Modify response data.
2122        data: HyperliquidExchangeModifyResponseData,
2123    },
2124    /// Generic response for other actions.
2125    #[serde(rename = "default")]
2126    Default,
2127    /// Catch-all for unknown response types.
2128    #[serde(other)]
2129    Unknown,
2130}
2131
2132/// Order response data containing status for each order from exchange endpoint.
2133#[derive(Debug, Clone, Serialize, Deserialize)]
2134pub struct HyperliquidExchangeOrderResponseData {
2135    /// Status for each order in the request.
2136    pub statuses: Vec<HyperliquidExchangeOrderStatus>,
2137}
2138
2139/// Cancel response data containing status for each cancellation from exchange endpoint.
2140#[derive(Debug, Clone, Serialize, Deserialize)]
2141pub struct HyperliquidExchangeCancelResponseData {
2142    /// Status for each cancellation in the request.
2143    pub statuses: Vec<HyperliquidExchangeCancelStatus>,
2144}
2145
2146/// Modify response data containing status for each modification from exchange endpoint.
2147#[derive(Debug, Clone, Serialize, Deserialize)]
2148pub struct HyperliquidExchangeModifyResponseData {
2149    /// Status for each modification in the request.
2150    pub statuses: Vec<HyperliquidExchangeModifyStatus>,
2151}
2152
2153/// Status of an individual order submission via exchange endpoint.
2154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2155#[serde(untagged)]
2156pub enum HyperliquidExchangeOrderStatus {
2157    /// Order is resting on the order book.
2158    Resting {
2159        /// Resting order information.
2160        resting: HyperliquidExchangeRestingInfo,
2161    },
2162    /// Order was filled immediately.
2163    Filled {
2164        /// Fill information.
2165        filled: HyperliquidExchangeFilledInfo,
2166    },
2167    /// Order submission failed.
2168    Error {
2169        /// Error message.
2170        error: String,
2171    },
2172    /// Bare status string for a trigger child of a `normalTpsl` group (SL/TP),
2173    /// which Hyperliquid serializes as a JSON string rather than an object
2174    /// (for example `"waitingForFill"` or `"waitingForTrigger"`).
2175    Tag(HyperliquidExchangeOrderStatusTag),
2176}
2177
2178/// Status tags Hyperliquid serializes as a bare JSON string.
2179///
2180/// Trigger children of a `normalTpsl` group, plus standalone trigger orders
2181/// that have not armed yet, fall in this bucket: the venue defers order-id
2182/// assignment until activation.
2183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2184pub enum HyperliquidExchangeOrderStatusTag {
2185    /// Trigger child parked until the parent (entry) order fills.
2186    #[serde(rename = "waitingForFill")]
2187    WaitingForFill,
2188    /// Trigger child parked until its trigger price condition is met.
2189    #[serde(rename = "waitingForTrigger")]
2190    WaitingForTrigger,
2191}
2192
2193/// Information about a resting order via exchange endpoint.
2194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2195pub struct HyperliquidExchangeRestingInfo {
2196    /// Order ID assigned by Hyperliquid.
2197    pub oid: OrderId,
2198}
2199
2200/// Information about a filled order via exchange endpoint.
2201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2202pub struct HyperliquidExchangeFilledInfo {
2203    /// Total filled size.
2204    #[serde(
2205        rename = "totalSz",
2206        serialize_with = "serialize_decimal_as_str",
2207        deserialize_with = "deserialize_decimal_from_str"
2208    )]
2209    pub total_sz: Decimal,
2210    /// Average fill price.
2211    #[serde(
2212        rename = "avgPx",
2213        serialize_with = "serialize_decimal_as_str",
2214        deserialize_with = "deserialize_decimal_from_str"
2215    )]
2216    pub avg_px: Decimal,
2217    /// Order ID.
2218    pub oid: OrderId,
2219}
2220
2221/// Status of an individual order cancellation via exchange endpoint.
2222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2223#[serde(untagged)]
2224pub enum HyperliquidExchangeCancelStatus {
2225    /// Cancellation succeeded.
2226    Success(String), // Usually "success"
2227    /// Cancellation failed.
2228    Error {
2229        /// Error message.
2230        error: String,
2231    },
2232}
2233
2234/// Status of an individual order modification via exchange endpoint.
2235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2236#[serde(untagged)]
2237pub enum HyperliquidExchangeModifyStatus {
2238    /// Modification succeeded.
2239    Success(String), // Usually "success"
2240    /// Modification failed.
2241    Error {
2242        /// Error message.
2243        error: String,
2244    },
2245}
2246
2247/// Complete clearinghouse state response from `POST /info` with `{ "type": "clearinghouseState", "user": "address" }`.
2248/// This provides account positions, margin information, and balances.
2249#[derive(Debug, Clone, Serialize, Deserialize)]
2250#[serde(rename_all = "camelCase")]
2251pub struct ClearinghouseState {
2252    /// List of asset positions (perpetual contracts).
2253    #[serde(default)]
2254    pub asset_positions: Vec<AssetPosition>,
2255    /// Cross margin summary information.
2256    #[serde(default)]
2257    pub cross_margin_summary: Option<CrossMarginSummary>,
2258    /// Withdrawable balance (top-level field).
2259    #[serde(
2260        default,
2261        serialize_with = "serialize_optional_decimal_as_str",
2262        deserialize_with = "deserialize_optional_decimal_from_str"
2263    )]
2264    pub withdrawable: Option<Decimal>,
2265    /// Time of the state snapshot (milliseconds since epoch).
2266    #[serde(default)]
2267    pub time: Option<u64>,
2268}
2269
2270/// A single asset position in the clearinghouse state.
2271#[derive(Debug, Clone, Serialize, Deserialize)]
2272#[serde(rename_all = "camelCase")]
2273pub struct AssetPosition {
2274    /// Position information.
2275    pub position: PositionData,
2276    /// Type of position.
2277    #[serde(rename = "type")]
2278    pub position_type: HyperliquidPositionType,
2279}
2280
2281/// Leverage information for a position.
2282#[derive(Debug, Clone, Serialize, Deserialize)]
2283#[serde(rename_all = "camelCase")]
2284pub struct LeverageInfo {
2285    #[serde(rename = "type")]
2286    pub leverage_type: HyperliquidLeverageType,
2287    /// Leverage value.
2288    pub value: u32,
2289}
2290
2291/// Cumulative funding breakdown for a position.
2292#[derive(Debug, Clone, Serialize, Deserialize)]
2293#[serde(rename_all = "camelCase")]
2294pub struct CumFundingInfo {
2295    /// All-time cumulative funding.
2296    #[serde(
2297        rename = "allTime",
2298        serialize_with = "serialize_decimal_as_str",
2299        deserialize_with = "deserialize_decimal_from_str"
2300    )]
2301    pub all_time: Decimal,
2302    /// Funding since position opened.
2303    #[serde(
2304        rename = "sinceOpen",
2305        serialize_with = "serialize_decimal_as_str",
2306        deserialize_with = "deserialize_decimal_from_str"
2307    )]
2308    pub since_open: Decimal,
2309    /// Funding since last position change.
2310    #[serde(
2311        rename = "sinceChange",
2312        serialize_with = "serialize_decimal_as_str",
2313        deserialize_with = "deserialize_decimal_from_str"
2314    )]
2315    pub since_change: Decimal,
2316}
2317
2318/// Detailed position data for an asset.
2319#[derive(Debug, Clone, Serialize, Deserialize)]
2320#[serde(rename_all = "camelCase")]
2321pub struct PositionData {
2322    /// Asset symbol/coin (e.g., "BTC").
2323    pub coin: Ustr,
2324    /// Cumulative funding breakdown.
2325    #[serde(rename = "cumFunding")]
2326    pub cum_funding: CumFundingInfo,
2327    /// Entry price for the position.
2328    #[serde(
2329        rename = "entryPx",
2330        serialize_with = "serialize_optional_decimal_as_str",
2331        deserialize_with = "deserialize_optional_decimal_from_str",
2332        default
2333    )]
2334    pub entry_px: Option<Decimal>,
2335    /// Leverage information for the position.
2336    pub leverage: LeverageInfo,
2337    /// Liquidation price.
2338    #[serde(
2339        rename = "liquidationPx",
2340        serialize_with = "serialize_optional_decimal_as_str",
2341        deserialize_with = "deserialize_optional_decimal_from_str",
2342        default
2343    )]
2344    pub liquidation_px: Option<Decimal>,
2345    /// Margin used for this position.
2346    #[serde(
2347        rename = "marginUsed",
2348        serialize_with = "serialize_decimal_as_str",
2349        deserialize_with = "deserialize_decimal_from_str"
2350    )]
2351    pub margin_used: Decimal,
2352    /// Maximum leverage allowed for this asset.
2353    #[serde(rename = "maxLeverage", default)]
2354    pub max_leverage: Option<u32>,
2355    /// Position value.
2356    #[serde(
2357        rename = "positionValue",
2358        serialize_with = "serialize_decimal_as_str",
2359        deserialize_with = "deserialize_decimal_from_str"
2360    )]
2361    pub position_value: Decimal,
2362    /// Return on equity percentage.
2363    #[serde(
2364        rename = "returnOnEquity",
2365        serialize_with = "serialize_decimal_as_str",
2366        deserialize_with = "deserialize_decimal_from_str"
2367    )]
2368    pub return_on_equity: Decimal,
2369    /// Position size (positive for long, negative for short).
2370    #[serde(
2371        rename = "szi",
2372        serialize_with = "serialize_decimal_as_str",
2373        deserialize_with = "deserialize_decimal_from_str"
2374    )]
2375    pub szi: Decimal,
2376    /// Unrealized PnL.
2377    #[serde(
2378        rename = "unrealizedPnl",
2379        serialize_with = "serialize_decimal_as_str",
2380        deserialize_with = "deserialize_decimal_from_str"
2381    )]
2382    pub unrealized_pnl: Decimal,
2383}
2384
2385/// Complete spot clearinghouse state response from `POST /info`
2386/// with `{ "type": "spotClearinghouseState", "user": "address" }`.
2387///
2388/// Provides per-token spot balances for the queried address. Under unified or
2389/// portfolio margin accounts this is the source of truth for spot holdings.
2390#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2391#[serde(rename_all = "camelCase")]
2392pub struct SpotClearinghouseState {
2393    /// Per-token spot balances.
2394    #[serde(default)]
2395    pub balances: Vec<SpotBalance>,
2396}
2397
2398/// A single token balance entry from `spotClearinghouseState.balances`.
2399#[derive(Debug, Clone, Serialize, Deserialize)]
2400#[serde(rename_all = "camelCase")]
2401pub struct SpotBalance {
2402    /// Token name (e.g., "USDC", "PURR").
2403    pub coin: Ustr,
2404    /// Token index matching `spotMeta.tokens[*].index`. Omitted by the venue
2405    /// for HIP-4 outcome side tokens (`+E` coins).
2406    #[serde(default)]
2407    pub token: Option<u32>,
2408    /// Total token balance (on-hold plus available).
2409    #[serde(
2410        serialize_with = "serialize_decimal_as_str",
2411        deserialize_with = "deserialize_decimal_from_str"
2412    )]
2413    pub total: Decimal,
2414    /// Portion currently reserved for resting orders.
2415    #[serde(
2416        serialize_with = "serialize_decimal_as_str",
2417        deserialize_with = "deserialize_decimal_from_str"
2418    )]
2419    pub hold: Decimal,
2420    /// Entry notional value (position cost basis in USDC).
2421    #[serde(
2422        default,
2423        serialize_with = "serialize_optional_decimal_as_str",
2424        deserialize_with = "deserialize_optional_decimal_from_str"
2425    )]
2426    pub entry_ntl: Option<Decimal>,
2427}
2428
2429impl SpotBalance {
2430    /// Returns the balance freely available to trade or withdraw (`total - hold`).
2431    #[must_use]
2432    pub fn free(&self) -> Decimal {
2433        (self.total - self.hold).max(Decimal::ZERO)
2434    }
2435
2436    /// Returns the average entry price derived from `entry_ntl / total`, if both are non-zero.
2437    #[must_use]
2438    pub fn avg_entry_px(&self) -> Option<Decimal> {
2439        let entry_ntl = self.entry_ntl?;
2440
2441        if entry_ntl.is_zero() || self.total.is_zero() {
2442            return None;
2443        }
2444
2445        Some(entry_ntl / self.total)
2446    }
2447}
2448
2449/// Cross margin summary information.
2450#[derive(Debug, Clone, Serialize, Deserialize)]
2451#[serde(rename_all = "camelCase")]
2452pub struct CrossMarginSummary {
2453    /// Account value in USD.
2454    #[serde(
2455        rename = "accountValue",
2456        serialize_with = "serialize_decimal_as_str",
2457        deserialize_with = "deserialize_decimal_from_str"
2458    )]
2459    pub account_value: Decimal,
2460    /// Total notional position value.
2461    #[serde(
2462        rename = "totalNtlPos",
2463        serialize_with = "serialize_decimal_as_str",
2464        deserialize_with = "deserialize_decimal_from_str"
2465    )]
2466    pub total_ntl_pos: Decimal,
2467    /// Total raw USD value (collateral).
2468    #[serde(
2469        rename = "totalRawUsd",
2470        serialize_with = "serialize_decimal_as_str",
2471        deserialize_with = "deserialize_decimal_from_str"
2472    )]
2473    pub total_raw_usd: Decimal,
2474    /// Total margin used across all positions.
2475    #[serde(
2476        rename = "totalMarginUsed",
2477        serialize_with = "serialize_decimal_as_str",
2478        deserialize_with = "deserialize_decimal_from_str"
2479    )]
2480    pub total_margin_used: Decimal,
2481    /// Withdrawable balance.
2482    #[serde(
2483        rename = "withdrawable",
2484        default,
2485        serialize_with = "serialize_optional_decimal_as_str",
2486        deserialize_with = "deserialize_optional_decimal_from_str"
2487    )]
2488    pub withdrawable: Option<Decimal>,
2489}
2490
2491fn serialize_order_decimal_as_str<S>(decimal: &Decimal, serializer: S) -> Result<S::Ok, S::Error>
2492where
2493    S: Serializer,
2494{
2495    serialize_decimal_as_str(&decimal.normalize(), serializer)
2496}