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
882    #[rstest]
883    fn test_signature_serialization_and_debug_redaction() {
884        let signature = HyperliquidSignature::new(
885            "0x1111111111111111111111111111111111111111111111111111111111111111".to_string(),
886            "0x2222222222222222222222222222222222222222222222222222222222222222".to_string(),
887            27,
888        );
889        let wire = serde_json::to_value(&signature).unwrap();
890        let debug = format!("{signature:?}");
891
892        assert_eq!(wire["r"], signature.r.expose_secret());
893        assert_eq!(wire["s"], signature.s.expose_secret());
894        assert_eq!(wire["v"], signature.v);
895        assert_eq!(debug.matches(REDACTED).count(), 2);
896        assert!(!debug.contains(signature.r.expose_secret()));
897        assert!(!debug.contains(signature.s.expose_secret()));
898    }
899
900    #[rstest]
901    fn test_exchange_action_request_debug_redacts_signature() {
902        let request = HyperliquidExchangeActionRequest {
903            action: HyperliquidExchangeAction::Noop,
904            nonce: 1_700_000_000_000,
905            signature: SecretString::from("0xsigned-action"),
906            vault_address: Some("0xvault".to_string()),
907            expires_after: Some(1_700_000_001_000),
908        };
909        let wire = serde_json::to_value(&request).unwrap();
910        let debug = format!("{request:?}");
911
912        assert_eq!(wire["signature"], "0xsigned-action");
913        assert_eq!(wire["nonce"], 1_700_000_000_000_u64);
914        assert!(debug.contains(REDACTED));
915        assert!(!debug.contains("0xsigned-action"));
916    }
917
918    #[rstest]
919    fn test_meta_deserialization() {
920        let json = r#"{"universe": [{"name": "BTC", "szDecimals": 5}]}"#;
921
922        let meta: HyperliquidMeta = serde_json::from_str(json).unwrap();
923
924        assert_eq!(meta.universe.len(), 1);
925        assert_eq!(meta.universe[0].name, "BTC");
926        assert_eq!(meta.universe[0].sz_decimals, 5);
927    }
928
929    #[rstest]
930    fn test_funding_history_entry_with_premium() {
931        let json = r#"{
932            "coin": "BTC",
933            "fundingRate": "0.0000125",
934            "premium": "0.00029005",
935            "time": 1769908800000
936        }"#;
937
938        let entry: HyperliquidFundingHistoryEntry = serde_json::from_str(json).unwrap();
939
940        assert_eq!(entry.coin, "BTC");
941        assert_eq!(entry.funding_rate, dec!(0.0000125));
942        assert_eq!(entry.premium, Some(dec!(0.00029005)));
943        assert_eq!(entry.time, 1769908800000);
944    }
945
946    #[rstest]
947    fn test_funding_history_entry_without_premium() {
948        // `premium` is optional in the venue response; it must deserialize
949        // to `None` when absent rather than fail.
950        let json = r#"{
951            "coin": "BTC",
952            "fundingRate": "0.0000033",
953            "time": 1769916000000
954        }"#;
955
956        let entry: HyperliquidFundingHistoryEntry = serde_json::from_str(json).unwrap();
957
958        assert!(entry.premium.is_none());
959        assert_eq!(entry.funding_rate, dec!(0.0000033));
960    }
961
962    #[rstest]
963    fn test_recent_trade_deserializes() {
964        // The venue payload carries `hash`/`users` fields the model ignores.
965        let json = r#"{
966            "coin": "BTC",
967            "side": "B",
968            "px": "104250.0",
969            "sz": "0.0123",
970            "hash": "0xabc",
971            "time": 1769916000000,
972            "tid": 987654321,
973            "users": ["0xbuyer", "0xseller"]
974        }"#;
975
976        let trade: HyperliquidRecentTrade = serde_json::from_str(json).unwrap();
977
978        assert_eq!(trade.coin, "BTC");
979        assert_eq!(trade.side, HyperliquidSide::Buy);
980        assert_eq!(trade.px, dec!(104250.0));
981        assert_eq!(trade.sz, dec!(0.0123));
982        assert_eq!(trade.time, 1769916000000);
983        assert_eq!(trade.tid, 987654321);
984    }
985
986    #[rstest]
987    fn test_order_status_deserializes_frontend_market_tif() {
988        let status: HyperliquidOrderStatus =
989            crate::common::testing::load_test_data("http_order_status_frontend_market.json");
990        let entry = status.into_order().expect("order status entry");
991
992        assert_eq!(entry.order.oid, 1);
993        assert_eq!(
994            entry.order.tif,
995            Some(HyperliquidTimeInForce::FrontendMarket)
996        );
997        assert_eq!(entry.status, HyperliquidOrderStatusEnum::Filled);
998    }
999
1000    #[rstest]
1001    fn test_historical_order_deserializes_liquidation_market_tif() {
1002        let entry: HyperliquidOrderStatusEntry =
1003            crate::common::testing::load_test_data("http_historical_order_liquidation_market.json");
1004
1005        assert_eq!(entry.order.oid, 42);
1006        assert_eq!(
1007            entry.order.tif,
1008            Some(HyperliquidTimeInForce::LiquidationMarket)
1009        );
1010        assert_eq!(entry.status, HyperliquidOrderStatusEnum::Filled);
1011    }
1012
1013    #[rstest]
1014    fn test_user_fill_deserializes_tid_and_builder_fee() {
1015        let json = r#"{
1016            "coin": "BTC",
1017            "px": "60000.5",
1018            "sz": "0.001",
1019            "side": "B",
1020            "time": 1704470400000,
1021            "startPosition": "0",
1022            "dir": "Open Long",
1023            "closedPnl": "1.25",
1024            "hash": "0xabc",
1025            "oid": 7001,
1026            "crossed": true,
1027            "fee": "0.02",
1028            "feeToken": "USDC",
1029            "tid": 9001,
1030            "builderFee": "0.001"
1031        }"#;
1032
1033        let fill: HyperliquidFill = serde_json::from_str(json).unwrap();
1034
1035        assert_eq!(fill.coin, "BTC");
1036        assert_eq!(fill.oid, 7001);
1037        assert_eq!(fill.tid, 9001);
1038        assert_eq!(fill.builder_fee, Some(dec!(0.001)));
1039        assert_eq!(fill.fee, dec!(0.02));
1040    }
1041
1042    #[rstest]
1043    fn test_user_fill_defaults_missing_tid_and_builder_fee() {
1044        let json = r#"{
1045            "coin": "ETH",
1046            "px": "2500.25",
1047            "sz": "0.5",
1048            "side": "A",
1049            "time": 1704470401000,
1050            "startPosition": "1.0",
1051            "dir": "Close Long",
1052            "closedPnl": "2.5",
1053            "hash": "0xdef",
1054            "oid": 8002,
1055            "crossed": false,
1056            "fee": "0.01",
1057            "feeToken": "USDC"
1058        }"#;
1059
1060        let fill: HyperliquidFill = serde_json::from_str(json).unwrap();
1061
1062        assert_eq!(fill.oid, 8002);
1063        assert_eq!(fill.tid, 0);
1064        assert_eq!(fill.builder_fee, None);
1065        assert_eq!(fill.fee, dec!(0.01));
1066        assert!(!fill.crossed);
1067    }
1068
1069    #[rstest]
1070    fn test_perp_asset_hip3_fields() {
1071        let json = r#"{
1072            "name": "xyz:TSLA",
1073            "szDecimals": 3,
1074            "maxLeverage": 10,
1075            "onlyIsolated": true,
1076            "growthMode": "enabled",
1077            "marginMode": "strictIsolated"
1078        }"#;
1079
1080        let asset: PerpAsset = serde_json::from_str(json).unwrap();
1081
1082        assert_eq!(asset.name, "xyz:TSLA");
1083        assert_eq!(asset.sz_decimals, 3);
1084        assert_eq!(asset.max_leverage, Some(10));
1085        assert_eq!(asset.only_isolated, Some(true));
1086        assert_eq!(asset.growth_mode.as_deref(), Some("enabled"));
1087        assert_eq!(asset.margin_mode.as_deref(), Some("strictIsolated"));
1088    }
1089
1090    #[rstest]
1091    fn test_perp_asset_hip3_fields_absent() {
1092        let json = r#"{"name": "BTC", "szDecimals": 5}"#;
1093
1094        let asset: PerpAsset = serde_json::from_str(json).unwrap();
1095
1096        assert_eq!(asset.growth_mode, None);
1097        assert_eq!(asset.margin_mode, None);
1098    }
1099
1100    #[rstest]
1101    fn test_outcome_meta_defaults_missing_side_specs() {
1102        let json = r#"{
1103            "outcomes": [
1104                {
1105                    "outcome": 123,
1106                    "name": "Recurring",
1107                    "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m"
1108                }
1109            ]
1110        }"#;
1111
1112        let meta: OutcomeMeta = serde_json::from_str(json).unwrap();
1113
1114        assert_eq!(meta.outcomes.len(), 1);
1115        assert_eq!(meta.outcomes[0].outcome, 123);
1116        assert!(meta.outcomes[0].side_specs.is_empty());
1117    }
1118
1119    #[rstest]
1120    fn test_l2_book_deserialization() {
1121        let json = r#"{"coin": "BTC", "levels": [[{"px": "50000", "sz": "1.5"}], [{"px": "50100", "sz": "2.0"}]], "time": 1234567890}"#;
1122
1123        let book: HyperliquidL2Book = serde_json::from_str(json).unwrap();
1124
1125        assert_eq!(book.coin, "BTC");
1126        assert_eq!(book.levels.len(), 2);
1127        assert_eq!(book.time, 1234567890);
1128    }
1129
1130    #[rstest]
1131    fn test_exchange_response_deserialization() {
1132        let json = r#"{"status": "ok", "response": {"type": "order"}}"#;
1133
1134        let response: HyperliquidExchangeResponse = serde_json::from_str(json).unwrap();
1135        assert!(response.is_ok());
1136    }
1137
1138    #[rstest]
1139    fn test_spot_clearinghouse_state_deserialization() {
1140        let json = r#"{
1141            "balances": [
1142                {"coin": "USDC", "token": 0, "total": "14.625485", "hold": "0.0", "entryNtl": "0.0"},
1143                {"coin": "PURR", "token": 1, "total": "2000", "hold": "100", "entryNtl": "1234.56"}
1144            ]
1145        }"#;
1146
1147        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
1148
1149        assert_eq!(state.balances.len(), 2);
1150        let usdc = &state.balances[0];
1151        assert_eq!(usdc.coin, "USDC");
1152        assert_eq!(usdc.token, Some(0));
1153        assert_eq!(usdc.total.to_string(), "14.625485");
1154        assert_eq!(usdc.hold, rust_decimal::Decimal::ZERO);
1155        assert_eq!(usdc.free().to_string(), "14.625485");
1156        assert_eq!(usdc.avg_entry_px(), None);
1157
1158        let purr = &state.balances[1];
1159        assert_eq!(purr.coin, "PURR");
1160        assert_eq!(purr.token, Some(1));
1161        assert_eq!(purr.free().to_string(), "1900");
1162        assert_eq!(
1163            purr.avg_entry_px().unwrap(),
1164            rust_decimal_macros::dec!(0.61728)
1165        );
1166    }
1167
1168    #[rstest]
1169    fn test_spot_balance_outcome_side_token_lacks_token_field() {
1170        // HIP-4 outcome side tokens come back without `token` from the venue
1171        let json = r#"{"coin": "+250", "total": "0.0", "hold": "0.0", "entryNtl": "0.0"}"#;
1172        let balance: SpotBalance = serde_json::from_str(json).unwrap();
1173        assert_eq!(balance.coin, "+250");
1174        assert_eq!(balance.token, None);
1175    }
1176
1177    #[rstest]
1178    fn test_spot_clearinghouse_state_empty() {
1179        let json = r#"{"balances": []}"#;
1180        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
1181        assert!(state.balances.is_empty());
1182    }
1183
1184    #[rstest]
1185    fn test_spot_balance_handles_missing_entry_ntl() {
1186        let json = r#"{"coin": "HYPE", "token": 150, "total": "5", "hold": "0"}"#;
1187        let balance: SpotBalance = serde_json::from_str(json).unwrap();
1188        assert_eq!(balance.entry_ntl, None);
1189        assert_eq!(balance.avg_entry_px(), None);
1190    }
1191
1192    #[rstest]
1193    fn test_msgpack_serialization_matches_python() {
1194        // Test that msgpack serialization includes the "type" tag properly.
1195        // Python SDK serializes: {"type": "order", "orders": [...], "grouping": "na"}
1196        // We need to verify rmp_serde::to_vec_named produces the same format.
1197
1198        let action = HyperliquidExchangeAction::Order {
1199            orders: vec![],
1200            grouping: HyperliquidExchangeGrouping::Na,
1201            builder: None,
1202        };
1203
1204        // First verify JSON is correct
1205        let json = serde_json::to_string(&action).unwrap();
1206        assert!(
1207            json.contains(r#""type":"order""#),
1208            "JSON should have type tag: {json}"
1209        );
1210
1211        // Serialize with msgpack
1212        let msgpack_bytes = rmp_serde::to_vec_named(&action).unwrap();
1213
1214        // Decode back to a generic Value to inspect the structure
1215        let decoded: serde_json::Value = rmp_serde::from_slice(&msgpack_bytes).unwrap();
1216
1217        // The decoded value should have a "type" field
1218        assert!(
1219            decoded.get("type").is_some(),
1220            "MsgPack should have type tag. Decoded: {decoded:?}"
1221        );
1222        assert_eq!(
1223            decoded.get("type").unwrap().as_str().unwrap(),
1224            "order",
1225            "Type should be 'order'"
1226        );
1227        assert!(decoded.get("orders").is_some(), "Should have orders field");
1228        assert!(
1229            decoded.get("grouping").is_some(),
1230            "Should have grouping field"
1231        );
1232    }
1233
1234    #[rstest]
1235    fn test_cancel_action_serialization(
1236        #[values(None, Some(false), Some(true))] fast: Option<bool>,
1237        #[values(false, true)] by_cloid: bool,
1238    ) {
1239        let (action, name) = if by_cloid {
1240            (
1241                HyperliquidExchangeAction::CancelByCloid {
1242                    cancels: vec![HyperliquidExchangeCancelByCloidRequest {
1243                        asset: 7,
1244                        cloid: Cloid::from_hex("0x0123456789abcdef0123456789abcdef").unwrap(),
1245                    }],
1246                    fast,
1247                },
1248                "cancel_by_cloid",
1249            )
1250        } else {
1251            (
1252                HyperliquidExchangeAction::Cancel {
1253                    cancels: vec![HyperliquidExchangeCancelOrderRequest {
1254                        asset: 7,
1255                        oid: 12345,
1256                    }],
1257                    fast,
1258                },
1259                "cancel",
1260            )
1261        };
1262
1263        let fixtures: serde_json::Value =
1264            serde_json::from_str(include_str!("../../test_data/cancel_actions.json")).unwrap();
1265        let expected = &fixtures[name][if fast == Some(true) { "fast" } else { "normal" }];
1266        let json = serde_json::to_value(&action).unwrap();
1267        let msgpack = rmp_serde::to_vec_named(&action).unwrap();
1268
1269        assert_eq!(json, expected["json"]);
1270        assert_eq!(hex::encode(msgpack), expected["msgpack"].as_str().unwrap());
1271    }
1272
1273    #[rstest]
1274    fn test_order_response_normal_tpsl_with_waiting_children() {
1275        // `normalTpsl` bracket: the entry rests with an oid, while the SL/TP
1276        // children come back as bare strings until the parent fills or the
1277        // trigger fires.
1278        let json = r#"{
1279            "statuses": [
1280                {"resting": {"oid": 446050656712}},
1281                "waitingForFill",
1282                "waitingForTrigger"
1283            ]
1284        }"#;
1285
1286        let data: HyperliquidExchangeOrderResponseData = serde_json::from_str(json).unwrap();
1287        assert_eq!(data.statuses.len(), 3);
1288
1289        assert!(matches!(
1290            data.statuses[0],
1291            HyperliquidExchangeOrderStatus::Resting { ref resting } if resting.oid == 446050656712
1292        ));
1293        assert!(matches!(
1294            data.statuses[1],
1295            HyperliquidExchangeOrderStatus::Tag(HyperliquidExchangeOrderStatusTag::WaitingForFill)
1296        ));
1297        assert!(matches!(
1298            data.statuses[2],
1299            HyperliquidExchangeOrderStatus::Tag(
1300                HyperliquidExchangeOrderStatusTag::WaitingForTrigger
1301            )
1302        ));
1303    }
1304
1305    #[rstest]
1306    fn test_user_outcome_split_serialization() {
1307        let action = HyperliquidExchangeAction::UserOutcome {
1308            op: HyperliquidExchangeUserOutcomeOp::SplitOutcome(
1309                HyperliquidExchangeSplitOutcomeParams {
1310                    outcome: 1,
1311                    amount: dec!(123.0),
1312                },
1313            ),
1314        };
1315
1316        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1317        assert_eq!(
1318            value,
1319            json!({
1320                "type": "userOutcome",
1321                "splitOutcome": { "outcome": 1, "amount": "123.0" }
1322            })
1323        );
1324    }
1325
1326    #[rstest]
1327    fn test_user_outcome_split_msgpack_roundtrip() {
1328        let action = HyperliquidExchangeAction::UserOutcome {
1329            op: HyperliquidExchangeUserOutcomeOp::SplitOutcome(
1330                HyperliquidExchangeSplitOutcomeParams {
1331                    outcome: 4,
1332                    amount: dec!(10),
1333                },
1334            ),
1335        };
1336
1337        let bytes = rmp_serde::to_vec_named(&action).unwrap();
1338        let decoded: serde_json::Value = rmp_serde::from_slice(&bytes).unwrap();
1339        assert_eq!(
1340            decoded,
1341            json!({
1342                "type": "userOutcome",
1343                "splitOutcome": { "outcome": 4, "amount": "10" }
1344            })
1345        );
1346    }
1347
1348    #[rstest]
1349    fn test_hyperliquid_level_serializes_decimals_as_strings() {
1350        // Decimal fields must serialize back to the string wire form, not a
1351        // JSON number.
1352        let level = HyperliquidLevel {
1353            px: dec!(98450.5),
1354            sz: dec!(2.5),
1355        };
1356        let value = serde_json::to_value(&level).unwrap();
1357        assert_eq!(value, json!({ "px": "98450.5", "sz": "2.5" }));
1358    }
1359
1360    #[rstest]
1361    fn test_user_outcome_merge_outcome_serialization() {
1362        let action = HyperliquidExchangeAction::UserOutcome {
1363            op: HyperliquidExchangeUserOutcomeOp::MergeOutcome(
1364                HyperliquidExchangeMergeOutcomeParams {
1365                    outcome: 1,
1366                    amount: Some(dec!(5.0)),
1367                },
1368            ),
1369        };
1370        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1371        assert_eq!(
1372            value,
1373            json!({
1374                "type": "userOutcome",
1375                "mergeOutcome": { "outcome": 1, "amount": "5.0" }
1376            })
1377        );
1378    }
1379
1380    #[rstest]
1381    fn test_user_outcome_merge_outcome_null_amount_means_max() {
1382        let action = HyperliquidExchangeAction::UserOutcome {
1383            op: HyperliquidExchangeUserOutcomeOp::MergeOutcome(
1384                HyperliquidExchangeMergeOutcomeParams {
1385                    outcome: 7,
1386                    amount: None,
1387                },
1388            ),
1389        };
1390        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1391        assert_eq!(
1392            value,
1393            json!({
1394                "type": "userOutcome",
1395                "mergeOutcome": { "outcome": 7, "amount": null }
1396            })
1397        );
1398    }
1399
1400    #[rstest]
1401    fn test_user_outcome_merge_question_serialization() {
1402        let action = HyperliquidExchangeAction::UserOutcome {
1403            op: HyperliquidExchangeUserOutcomeOp::MergeQuestion(
1404                HyperliquidExchangeMergeQuestionParams {
1405                    question: 9,
1406                    amount: Some(dec!(2.0)),
1407                },
1408            ),
1409        };
1410        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1411        assert_eq!(
1412            value,
1413            json!({
1414                "type": "userOutcome",
1415                "mergeQuestion": { "question": 9, "amount": "2.0" }
1416            })
1417        );
1418    }
1419
1420    #[rstest]
1421    fn test_user_outcome_merge_question_null_amount_means_max() {
1422        let action = HyperliquidExchangeAction::UserOutcome {
1423            op: HyperliquidExchangeUserOutcomeOp::MergeQuestion(
1424                HyperliquidExchangeMergeQuestionParams {
1425                    question: 9,
1426                    amount: None,
1427                },
1428            ),
1429        };
1430        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1431        assert_eq!(
1432            value,
1433            json!({
1434                "type": "userOutcome",
1435                "mergeQuestion": { "question": 9, "amount": null }
1436            })
1437        );
1438    }
1439
1440    #[rstest]
1441    fn test_user_outcome_negate_outcome_serialization() {
1442        let action = HyperliquidExchangeAction::UserOutcome {
1443            op: HyperliquidExchangeUserOutcomeOp::NegateOutcome(
1444                HyperliquidExchangeNegateOutcomeParams {
1445                    question: 9,
1446                    outcome: 52,
1447                    amount: dec!(1.5),
1448                },
1449            ),
1450        };
1451        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1452        assert_eq!(
1453            value,
1454            json!({
1455                "type": "userOutcome",
1456                "negateOutcome": { "question": 9, "outcome": 52, "amount": "1.5" }
1457            })
1458        );
1459    }
1460
1461    #[rstest]
1462    fn test_modify_target_serializes_numeric_oid() {
1463        let request = modify_request_with_target(HyperliquidExchangeModifyTarget::Oid(12345));
1464        let value: serde_json::Value = serde_json::to_value(request).unwrap();
1465
1466        assert_eq!(value["oid"], json!(12345));
1467    }
1468
1469    #[rstest]
1470    fn test_modify_target_serializes_cloid() {
1471        let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
1472        let request = modify_request_with_target(HyperliquidExchangeModifyTarget::Cloid(cloid));
1473        let value: serde_json::Value = serde_json::to_value(request).unwrap();
1474
1475        assert_eq!(value["oid"], json!("0x1234567890abcdef1234567890abcdef"));
1476    }
1477
1478    #[rstest]
1479    #[case("51000.00", "0.2500", "52000.000", "51000", "0.25", "52000")]
1480    #[case("0.00", "0.0000", "-0.000", "0", "0", "0")]
1481    #[case("-12.3400", "2.500", "-10.0100", "-12.34", "2.5", "-10.01")]
1482    #[case(
1483        "0.0000000000000000000000000001",
1484        "1.000",
1485        "10.0100",
1486        "0.0000000000000000000000000001",
1487        "1",
1488        "10.01"
1489    )]
1490    #[case(
1491        "79228162514264337593543950335",
1492        "0.2500",
1493        "52000.00",
1494        "79228162514264337593543950335",
1495        "0.25",
1496        "52000"
1497    )]
1498    fn test_order_decimals_serialize_without_trailing_zeros(
1499        #[case] price: &str,
1500        #[case] size: &str,
1501        #[case] trigger_price: &str,
1502        #[case] expected_price: &str,
1503        #[case] expected_size: &str,
1504        #[case] expected_trigger_price: &str,
1505        #[values(false, true)] is_market: bool,
1506    ) {
1507        let request = HyperliquidExchangePlaceOrderRequest {
1508            asset: 7,
1509            is_buy: false,
1510            price: price.parse().unwrap(),
1511            size: size.parse().unwrap(),
1512            reduce_only: true,
1513            kind: HyperliquidExchangeOrderKind::Trigger {
1514                trigger: HyperliquidExchangeTriggerParams {
1515                    is_market,
1516                    trigger_px: trigger_price.parse().unwrap(),
1517                    tpsl: HyperliquidExchangeTpSl::Tp,
1518                },
1519            },
1520            cloid: Some(Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap()),
1521        };
1522
1523        let expected = json!({
1524            "a": 7, "b": false, "p": expected_price, "s": expected_size, "r": true,
1525            "t": {"trigger": {"isMarket": is_market, "triggerPx": expected_trigger_price, "tpsl": "tp"}},
1526            "c": "0x1234567890abcdef1234567890abcdef",
1527        });
1528        let canonical: HyperliquidExchangePlaceOrderRequest =
1529            serde_json::from_value(expected.clone()).unwrap();
1530        let json = serde_json::to_value(&request).unwrap();
1531        let msgpack = rmp_serde::to_vec_named(&request).unwrap();
1532        let decoded: serde_json::Value = rmp_serde::from_slice(&msgpack).unwrap();
1533
1534        assert_eq!(json, expected);
1535        assert_eq!(decoded, expected);
1536        assert_eq!(msgpack, rmp_serde::to_vec_named(&canonical).unwrap());
1537        assert_eq!(
1538            serde_json::to_vec(&request).unwrap(),
1539            serde_json::to_vec(&canonical).unwrap()
1540        );
1541        assert_eq!(request.price.to_string(), price);
1542        assert_eq!(request.size.to_string(), size);
1543        assert_eq!(canonical, request);
1544    }
1545
1546    fn modify_request_with_target(
1547        oid: HyperliquidExchangeModifyTarget,
1548    ) -> HyperliquidExchangeModifyOrderRequest {
1549        HyperliquidExchangeModifyOrderRequest {
1550            oid,
1551            order: HyperliquidExchangePlaceOrderRequest {
1552                asset: 0,
1553                is_buy: true,
1554                price: dec!(51000),
1555                size: dec!(0.2),
1556                reduce_only: false,
1557                kind: HyperliquidExchangeOrderKind::Limit {
1558                    limit: HyperliquidExchangeLimitParams {
1559                        tif: HyperliquidExchangeTif::Gtc,
1560                    },
1561                },
1562                cloid: None,
1563            },
1564        }
1565    }
1566}
1567
1568/// Time-in-force for limit orders in exchange endpoint.
1569///
1570/// These values must match exactly what Hyperliquid expects for proper serialization.
1571#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1572pub enum HyperliquidExchangeTif {
1573    /// Add Liquidity Only (post-only order).
1574    #[serde(rename = "Alo")]
1575    Alo,
1576    /// Immediate or Cancel.
1577    #[serde(rename = "Ioc")]
1578    Ioc,
1579    /// Good Till Canceled.
1580    #[serde(rename = "Gtc")]
1581    Gtc,
1582}
1583
1584/// Take profit or stop loss side for trigger orders in exchange endpoint.
1585#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1586pub enum HyperliquidExchangeTpSl {
1587    /// Take profit.
1588    #[serde(rename = "tp")]
1589    Tp,
1590    /// Stop loss.
1591    #[serde(rename = "sl")]
1592    Sl,
1593}
1594
1595/// Order grouping strategy for linked TP/SL orders in exchange endpoint.
1596#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1597pub enum HyperliquidExchangeGrouping {
1598    /// No grouping semantics.
1599    #[serde(rename = "na")]
1600    #[default]
1601    Na,
1602    /// Normal TP/SL grouping (linked orders).
1603    #[serde(rename = "normalTpsl")]
1604    NormalTpsl,
1605    /// Position-level TP/SL grouping.
1606    #[serde(rename = "positionTpsl")]
1607    PositionTpsl,
1608}
1609
1610/// Order kind specification for the `t` field in exchange endpoint order requests.
1611#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1612#[serde(untagged)]
1613pub enum HyperliquidExchangeOrderKind {
1614    /// Limit order with time-in-force.
1615    Limit {
1616        /// Limit order parameters.
1617        limit: HyperliquidExchangeLimitParams,
1618    },
1619    /// Trigger order (stop/take profit).
1620    Trigger {
1621        /// Trigger order parameters.
1622        trigger: HyperliquidExchangeTriggerParams,
1623    },
1624}
1625
1626/// Parameters for limit orders in exchange endpoint.
1627#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1628pub struct HyperliquidExchangeLimitParams {
1629    /// Time-in-force for the limit order.
1630    pub tif: HyperliquidExchangeTif,
1631}
1632
1633/// Parameters for trigger orders (stop/take profit) in exchange endpoint.
1634#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1635#[serde(rename_all = "camelCase")]
1636pub struct HyperliquidExchangeTriggerParams {
1637    /// Whether to use market price when triggered.
1638    pub is_market: bool,
1639    /// Trigger price as a string with no trailing zeros.
1640    #[serde(
1641        serialize_with = "serialize_order_decimal_as_str",
1642        deserialize_with = "deserialize_decimal_from_str"
1643    )]
1644    pub trigger_px: Decimal,
1645    /// Whether this is a take profit or stop loss.
1646    pub tpsl: HyperliquidExchangeTpSl,
1647}
1648
1649/// Builder code for order attribution in the exchange endpoint.
1650///
1651/// The fee is specified in tenths of a basis point.
1652/// For example, `f: 10` represents 1 basis point (0.01%).
1653#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1654pub struct HyperliquidExchangeBuilderFee {
1655    /// Builder address for attribution.
1656    #[serde(rename = "b")]
1657    pub address: String,
1658    /// Fee in tenths of a basis point.
1659    #[serde(rename = "f")]
1660    pub fee_tenths_bp: u32,
1661}
1662
1663/// Order specification for placing orders via exchange endpoint.
1664///
1665/// This struct represents a single order in the exact format expected
1666/// by the Hyperliquid exchange endpoint.
1667#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1668pub struct HyperliquidExchangePlaceOrderRequest {
1669    /// Asset ID.
1670    #[serde(rename = "a")]
1671    pub asset: AssetId,
1672    /// Is buy order (true for buy, false for sell).
1673    #[serde(rename = "b")]
1674    pub is_buy: bool,
1675    /// Price as a string with no trailing zeros.
1676    #[serde(
1677        rename = "p",
1678        serialize_with = "serialize_order_decimal_as_str",
1679        deserialize_with = "deserialize_decimal_from_str"
1680    )]
1681    pub price: Decimal,
1682    /// Size as a string with no trailing zeros.
1683    #[serde(
1684        rename = "s",
1685        serialize_with = "serialize_order_decimal_as_str",
1686        deserialize_with = "deserialize_decimal_from_str"
1687    )]
1688    pub size: Decimal,
1689    /// Reduce-only flag.
1690    #[serde(rename = "r")]
1691    pub reduce_only: bool,
1692    /// Order type (limit or trigger).
1693    #[serde(rename = "t")]
1694    pub kind: HyperliquidExchangeOrderKind,
1695    /// Optional client order ID (128-bit hex).
1696    #[serde(rename = "c", skip_serializing_if = "Option::is_none")]
1697    pub cloid: Option<Cloid>,
1698}
1699
1700/// Cancel specification for canceling orders by order ID via exchange endpoint.
1701#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1702pub struct HyperliquidExchangeCancelOrderRequest {
1703    /// Asset ID.
1704    #[serde(rename = "a")]
1705    pub asset: AssetId,
1706    /// Order ID to cancel.
1707    #[serde(rename = "o")]
1708    pub oid: OrderId,
1709}
1710
1711/// Cancel specification for canceling orders by client order ID via exchange endpoint.
1712///
1713/// Note: Unlike order placement which uses abbreviated field names ("a", "c"),
1714/// cancel-by-cloid uses full field names ("asset", "cloid") per the Hyperliquid API.
1715#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1716pub struct HyperliquidExchangeCancelByCloidRequest {
1717    /// Asset ID.
1718    pub asset: AssetId,
1719    /// Client order ID to cancel.
1720    pub cloid: Cloid,
1721}
1722
1723/// Target of a modify request.
1724///
1725/// Hyperliquid names this field `oid`, but accepts either a numeric venue
1726/// order ID or a CLOID.
1727#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1728#[serde(untagged)]
1729pub enum HyperliquidExchangeModifyTarget {
1730    /// Numeric venue order ID.
1731    Oid(OrderId),
1732    /// CLOID.
1733    Cloid(Cloid),
1734}
1735
1736impl HyperliquidExchangeModifyTarget {
1737    /// Creates a numeric modify target from a Nautilus venue order ID.
1738    ///
1739    /// # Errors
1740    ///
1741    /// Returns an error if the venue order ID is not a numeric Hyperliquid order ID.
1742    pub fn from_venue_order_id(
1743        venue_order_id: &VenueOrderId,
1744    ) -> Result<Self, std::num::ParseIntError> {
1745        venue_order_id.as_str().parse::<OrderId>().map(Self::Oid)
1746    }
1747}
1748
1749impl From<OrderId> for HyperliquidExchangeModifyTarget {
1750    fn from(value: OrderId) -> Self {
1751        Self::Oid(value)
1752    }
1753}
1754
1755impl From<Cloid> for HyperliquidExchangeModifyTarget {
1756    fn from(value: Cloid) -> Self {
1757        Self::Cloid(value)
1758    }
1759}
1760
1761/// Modify specification for modifying existing orders via exchange endpoint.
1762///
1763/// The HL API requires the full order spec (same as a place order) plus
1764/// the venue order ID or CLOID to modify.
1765#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1766pub struct HyperliquidExchangeModifyOrderRequest {
1767    /// Venue order ID or CLOID to modify.
1768    pub oid: HyperliquidExchangeModifyTarget,
1769    /// Full replacement order specification.
1770    pub order: HyperliquidExchangePlaceOrderRequest,
1771}
1772
1773/// Parameters for the HIP-4 `splitOutcome` operation inside a `userOutcome` action.
1774///
1775/// Debits `amount` quote tokens from the user's spot balance and credits both
1776/// the Yes and No side tokens of the referenced outcome.
1777#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1778pub struct HyperliquidExchangeSplitOutcomeParams {
1779    /// Outcome index (matches `outcomeMeta.outcomes[i].outcome`).
1780    pub outcome: u32,
1781    /// Quote-token amount to split, serialized as a decimal string (e.g. `"123.0"`).
1782    #[serde(
1783        serialize_with = "serialize_decimal_as_str",
1784        deserialize_with = "deserialize_decimal_from_str"
1785    )]
1786    pub amount: Decimal,
1787}
1788
1789/// Parameters for the HIP-4 `mergeOutcome` operation inside a `userOutcome` action.
1790///
1791/// Burns `amount` matched Yes + No side tokens of `outcome` for `amount` quote
1792/// tokens back. `amount = None` serializes as `null`, which the venue treats as
1793/// the maximum mergeable balance.
1794#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1795pub struct HyperliquidExchangeMergeOutcomeParams {
1796    /// Outcome index whose Yes + No pair is being merged.
1797    pub outcome: u32,
1798    /// Side-token amount to merge, or `None` to merge the maximum available.
1799    #[serde(
1800        default,
1801        serialize_with = "serialize_optional_decimal_as_str",
1802        deserialize_with = "deserialize_optional_decimal_from_str"
1803    )]
1804    pub amount: Option<Decimal>,
1805}
1806
1807/// Parameters for the HIP-4 `mergeQuestion` operation inside a `userOutcome` action.
1808///
1809/// Burns `amount` Yes shares of every outcome associated with `question` for
1810/// `amount` quote tokens back. `amount = None` serializes as `null`, meaning
1811/// the maximum mergeable balance.
1812#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1813pub struct HyperliquidExchangeMergeQuestionParams {
1814    /// Question identifier whose named outcomes are being merged.
1815    pub question: u32,
1816    /// Yes-share amount to merge per outcome, or `None` for the max.
1817    #[serde(
1818        default,
1819        serialize_with = "serialize_optional_decimal_as_str",
1820        deserialize_with = "deserialize_optional_decimal_from_str"
1821    )]
1822    pub amount: Option<Decimal>,
1823}
1824
1825/// Parameters for the HIP-4 `negateOutcome` operation inside a `userOutcome` action.
1826///
1827/// Converts `amount` `No` shares of `outcome` (within `question`) into `amount`
1828/// `Yes` shares of every other outcome in the same question.
1829#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1830pub struct HyperliquidExchangeNegateOutcomeParams {
1831    /// Question identifier the outcome belongs to.
1832    pub question: u32,
1833    /// Outcome index whose `No` shares are being negated.
1834    pub outcome: u32,
1835    /// Side-token amount to negate, serialized as a decimal string.
1836    #[serde(
1837        serialize_with = "serialize_decimal_as_str",
1838        deserialize_with = "deserialize_decimal_from_str"
1839    )]
1840    pub amount: Decimal,
1841}
1842
1843/// Operations carried by the [`HyperliquidExchangeAction::UserOutcome`] action.
1844///
1845/// Each variant serializes as a single-keyed object (for example,
1846/// `{ "splitOutcome": { ... } }`) and is flattened into the outer action
1847/// envelope alongside `"type": "userOutcome"` to match the Hyperliquid wire
1848/// format.
1849#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1850pub enum HyperliquidExchangeUserOutcomeOp {
1851    /// Split `amount` quote tokens into `amount` Yes plus `amount` No shares.
1852    #[serde(rename = "splitOutcome")]
1853    SplitOutcome(HyperliquidExchangeSplitOutcomeParams),
1854    /// Merge `amount` Yes + No side-token pairs of `outcome` back into quote
1855    /// tokens (reverse of [`Self::SplitOutcome`]).
1856    #[serde(rename = "mergeOutcome")]
1857    MergeOutcome(HyperliquidExchangeMergeOutcomeParams),
1858    /// Merge `amount` Yes shares of every outcome in `question` into quote
1859    /// tokens (multi-outcome reverse of `splitOutcome`).
1860    #[serde(rename = "mergeQuestion")]
1861    MergeQuestion(HyperliquidExchangeMergeQuestionParams),
1862    /// Swap `amount` `No` shares of one outcome into `Yes` shares of every
1863    /// other outcome in the same question.
1864    #[serde(rename = "negateOutcome")]
1865    NegateOutcome(HyperliquidExchangeNegateOutcomeParams),
1866}
1867
1868/// TWAP (Time-Weighted Average Price) order specification for exchange endpoint.
1869#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1870pub struct HyperliquidExchangeTwapRequest {
1871    /// Asset ID.
1872    #[serde(rename = "a")]
1873    pub asset: AssetId,
1874    /// Is buy order.
1875    #[serde(rename = "b")]
1876    pub is_buy: bool,
1877    /// Total size to execute.
1878    #[serde(
1879        rename = "s",
1880        serialize_with = "serialize_decimal_as_str",
1881        deserialize_with = "deserialize_decimal_from_str"
1882    )]
1883    pub size: Decimal,
1884    /// Duration in milliseconds.
1885    #[serde(rename = "m")]
1886    pub duration_ms: u64,
1887}
1888
1889/// All possible exchange actions for the Hyperliquid `/exchange` endpoint.
1890///
1891/// Each variant corresponds to a specific action type that can be performed
1892/// through the exchange API. The serialization uses the exact action type
1893/// names expected by Hyperliquid.
1894#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1895#[serde(tag = "type")]
1896pub enum HyperliquidExchangeAction {
1897    /// Place one or more orders.
1898    #[serde(rename = "order")]
1899    Order {
1900        /// List of orders to place.
1901        orders: Vec<HyperliquidExchangePlaceOrderRequest>,
1902        /// Grouping strategy for TP/SL orders.
1903        #[serde(default)]
1904        grouping: HyperliquidExchangeGrouping,
1905        /// Optional builder code for attribution.
1906        #[serde(skip_serializing_if = "Option::is_none")]
1907        builder: Option<HyperliquidExchangeBuilderFee>,
1908    },
1909
1910    /// Cancel orders by order ID.
1911    #[serde(rename = "cancel")]
1912    Cancel {
1913        /// Orders to cancel.
1914        cancels: Vec<HyperliquidExchangeCancelOrderRequest>,
1915        /// Optional fast-cancel flag, serialized only when true.
1916        #[serde(rename = "f", skip_serializing_if = "is_false_or_none")]
1917        fast: Option<bool>,
1918    },
1919
1920    /// Cancel orders by client order ID.
1921    #[serde(rename = "cancelByCloid")]
1922    CancelByCloid {
1923        /// Orders to cancel by CLOID.
1924        cancels: Vec<HyperliquidExchangeCancelByCloidRequest>,
1925        /// Optional fast-cancel flag, serialized only when true.
1926        #[serde(rename = "f", skip_serializing_if = "is_false_or_none")]
1927        fast: Option<bool>,
1928    },
1929
1930    /// Modify a single order.
1931    #[serde(rename = "modify")]
1932    Modify {
1933        /// Order modification specification.
1934        #[serde(flatten)]
1935        modify: HyperliquidExchangeModifyOrderRequest,
1936    },
1937
1938    /// Modify multiple orders in one action.
1939    #[serde(rename = "batchModify")]
1940    BatchModify {
1941        /// Multiple order modifications.
1942        modifies: Vec<HyperliquidExchangeModifyOrderRequest>,
1943    },
1944
1945    /// Schedule automatic order cancellation (dead man's switch).
1946    #[serde(rename = "scheduleCancel")]
1947    ScheduleCancel {
1948        /// Time in milliseconds when orders should be cancelled.
1949        /// If None, clears the existing schedule.
1950        #[serde(skip_serializing_if = "Option::is_none")]
1951        time: Option<u64>,
1952    },
1953
1954    /// Update leverage for a position.
1955    #[serde(rename = "updateLeverage")]
1956    UpdateLeverage {
1957        /// Asset ID.
1958        #[serde(rename = "a")]
1959        asset: AssetId,
1960        /// Whether to use cross margin.
1961        #[serde(rename = "isCross")]
1962        is_cross: bool,
1963        /// Leverage value.
1964        #[serde(rename = "leverage")]
1965        leverage: u32,
1966    },
1967
1968    /// Update isolated margin for a position.
1969    #[serde(rename = "updateIsolatedMargin")]
1970    UpdateIsolatedMargin {
1971        /// Asset ID.
1972        #[serde(rename = "a")]
1973        asset: AssetId,
1974        /// Margin delta as a string.
1975        #[serde(
1976            rename = "delta",
1977            serialize_with = "serialize_decimal_as_str",
1978            deserialize_with = "deserialize_decimal_from_str"
1979        )]
1980        delta: Decimal,
1981    },
1982
1983    /// Transfer USD between spot and perp accounts.
1984    #[serde(rename = "usdClassTransfer")]
1985    UsdClassTransfer {
1986        /// Source account type.
1987        from: String,
1988        /// Destination account type.
1989        to: String,
1990        /// Amount to transfer.
1991        #[serde(
1992            serialize_with = "serialize_decimal_as_str",
1993            deserialize_with = "deserialize_decimal_from_str"
1994        )]
1995        amount: Decimal,
1996    },
1997
1998    /// HIP-4 outcome-side token management (`splitOutcome` and related ops).
1999    ///
2000    /// The active op is carried via [`HyperliquidExchangeUserOutcomeOp`] and
2001    /// flattened into this action envelope, producing wire payloads such as
2002    /// `{ "type": "userOutcome", "splitOutcome": { ... } }`.
2003    #[serde(rename = "userOutcome")]
2004    UserOutcome {
2005        /// Operation to perform on the user's outcome balances.
2006        #[serde(flatten)]
2007        op: HyperliquidExchangeUserOutcomeOp,
2008    },
2009
2010    /// Place a TWAP order.
2011    #[serde(rename = "twapPlace")]
2012    TwapPlace {
2013        /// TWAP order specification.
2014        #[serde(flatten)]
2015        twap: HyperliquidExchangeTwapRequest,
2016    },
2017
2018    /// Cancel a TWAP order.
2019    #[serde(rename = "twapCancel")]
2020    TwapCancel {
2021        /// Asset ID.
2022        #[serde(rename = "a")]
2023        asset: AssetId,
2024        /// TWAP ID.
2025        #[serde(rename = "t")]
2026        twap_id: u64,
2027    },
2028
2029    /// No-operation to invalidate pending nonces.
2030    #[serde(rename = "noop")]
2031    Noop,
2032}
2033
2034/// Typed exchange action request envelope for the `/exchange` endpoint.
2035///
2036/// This is the top-level structure sent to Hyperliquid's exchange endpoint.
2037/// It includes the action to perform along with authentication and metadata.
2038#[derive(Debug, Clone, Serialize)]
2039#[serde(rename_all = "camelCase")]
2040pub struct HyperliquidExchangeActionRequest {
2041    /// The exchange action to perform.
2042    pub action: HyperliquidExchangeAction,
2043    /// Request nonce for replay protection (milliseconds timestamp recommended).
2044    pub nonce: u64,
2045    /// ECC signature over the action and nonce.
2046    pub signature: SecretString,
2047    /// Optional vault address for sub-account trading.
2048    #[serde(skip_serializing_if = "Option::is_none")]
2049    pub vault_address: Option<String>,
2050    /// Optional expiration time in milliseconds.
2051    /// Note: Using this field increases rate limit weight by 5x if the request expires.
2052    #[serde(skip_serializing_if = "Option::is_none")]
2053    pub expires_after: Option<u64>,
2054}
2055
2056/// Typed exchange action response envelope from the `/exchange` endpoint.
2057#[derive(Debug, Clone, Serialize, Deserialize)]
2058pub struct HyperliquidExchangeActionResponse {
2059    /// Response status ("ok" for success).
2060    pub status: String,
2061    /// Response payload.
2062    pub response: HyperliquidExchangeResponseData,
2063}
2064
2065/// Response data containing the actual response payload from exchange endpoint.
2066#[derive(Debug, Clone, Serialize, Deserialize)]
2067#[serde(tag = "type")]
2068pub enum HyperliquidExchangeResponseData {
2069    /// Response for order actions.
2070    #[serde(rename = "order")]
2071    Order {
2072        /// Order response data.
2073        data: HyperliquidExchangeOrderResponseData,
2074    },
2075    /// Response for cancel actions.
2076    #[serde(rename = "cancel")]
2077    Cancel {
2078        /// Cancel response data.
2079        data: HyperliquidExchangeCancelResponseData,
2080    },
2081    /// Response for modify actions.
2082    #[serde(rename = "modify")]
2083    Modify {
2084        /// Modify response data.
2085        data: HyperliquidExchangeModifyResponseData,
2086    },
2087    /// Generic response for other actions.
2088    #[serde(rename = "default")]
2089    Default,
2090    /// Catch-all for unknown response types.
2091    #[serde(other)]
2092    Unknown,
2093}
2094
2095/// Order response data containing status for each order from exchange endpoint.
2096#[derive(Debug, Clone, Serialize, Deserialize)]
2097pub struct HyperliquidExchangeOrderResponseData {
2098    /// Status for each order in the request.
2099    pub statuses: Vec<HyperliquidExchangeOrderStatus>,
2100}
2101
2102/// Cancel response data containing status for each cancellation from exchange endpoint.
2103#[derive(Debug, Clone, Serialize, Deserialize)]
2104pub struct HyperliquidExchangeCancelResponseData {
2105    /// Status for each cancellation in the request.
2106    pub statuses: Vec<HyperliquidExchangeCancelStatus>,
2107}
2108
2109/// Modify response data containing status for each modification from exchange endpoint.
2110#[derive(Debug, Clone, Serialize, Deserialize)]
2111pub struct HyperliquidExchangeModifyResponseData {
2112    /// Status for each modification in the request.
2113    pub statuses: Vec<HyperliquidExchangeModifyStatus>,
2114}
2115
2116/// Status of an individual order submission via exchange endpoint.
2117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2118#[serde(untagged)]
2119pub enum HyperliquidExchangeOrderStatus {
2120    /// Order is resting on the order book.
2121    Resting {
2122        /// Resting order information.
2123        resting: HyperliquidExchangeRestingInfo,
2124    },
2125    /// Order was filled immediately.
2126    Filled {
2127        /// Fill information.
2128        filled: HyperliquidExchangeFilledInfo,
2129    },
2130    /// Order submission failed.
2131    Error {
2132        /// Error message.
2133        error: String,
2134    },
2135    /// Bare status string for a trigger child of a `normalTpsl` group (SL/TP),
2136    /// which Hyperliquid serializes as a JSON string rather than an object
2137    /// (for example `"waitingForFill"` or `"waitingForTrigger"`).
2138    Tag(HyperliquidExchangeOrderStatusTag),
2139}
2140
2141/// Status tags Hyperliquid serializes as a bare JSON string.
2142///
2143/// Trigger children of a `normalTpsl` group, plus standalone trigger orders
2144/// that have not armed yet, fall in this bucket: the venue defers order-id
2145/// assignment until activation.
2146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2147pub enum HyperliquidExchangeOrderStatusTag {
2148    /// Trigger child parked until the parent (entry) order fills.
2149    #[serde(rename = "waitingForFill")]
2150    WaitingForFill,
2151    /// Trigger child parked until its trigger price condition is met.
2152    #[serde(rename = "waitingForTrigger")]
2153    WaitingForTrigger,
2154}
2155
2156/// Information about a resting order via exchange endpoint.
2157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2158pub struct HyperliquidExchangeRestingInfo {
2159    /// Order ID assigned by Hyperliquid.
2160    pub oid: OrderId,
2161}
2162
2163/// Information about a filled order via exchange endpoint.
2164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2165pub struct HyperliquidExchangeFilledInfo {
2166    /// Total filled size.
2167    #[serde(
2168        rename = "totalSz",
2169        serialize_with = "serialize_decimal_as_str",
2170        deserialize_with = "deserialize_decimal_from_str"
2171    )]
2172    pub total_sz: Decimal,
2173    /// Average fill price.
2174    #[serde(
2175        rename = "avgPx",
2176        serialize_with = "serialize_decimal_as_str",
2177        deserialize_with = "deserialize_decimal_from_str"
2178    )]
2179    pub avg_px: Decimal,
2180    /// Order ID.
2181    pub oid: OrderId,
2182}
2183
2184/// Status of an individual order cancellation via exchange endpoint.
2185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2186#[serde(untagged)]
2187pub enum HyperliquidExchangeCancelStatus {
2188    /// Cancellation succeeded.
2189    Success(String), // Usually "success"
2190    /// Cancellation failed.
2191    Error {
2192        /// Error message.
2193        error: String,
2194    },
2195}
2196
2197/// Status of an individual order modification via exchange endpoint.
2198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2199#[serde(untagged)]
2200pub enum HyperliquidExchangeModifyStatus {
2201    /// Modification succeeded.
2202    Success(String), // Usually "success"
2203    /// Modification failed.
2204    Error {
2205        /// Error message.
2206        error: String,
2207    },
2208}
2209
2210/// Complete clearinghouse state response from `POST /info` with `{ "type": "clearinghouseState", "user": "address" }`.
2211/// This provides account positions, margin information, and balances.
2212#[derive(Debug, Clone, Serialize, Deserialize)]
2213#[serde(rename_all = "camelCase")]
2214pub struct ClearinghouseState {
2215    /// List of asset positions (perpetual contracts).
2216    #[serde(default)]
2217    pub asset_positions: Vec<AssetPosition>,
2218    /// Cross margin summary information.
2219    #[serde(default)]
2220    pub cross_margin_summary: Option<CrossMarginSummary>,
2221    /// Withdrawable balance (top-level field).
2222    #[serde(
2223        default,
2224        serialize_with = "serialize_optional_decimal_as_str",
2225        deserialize_with = "deserialize_optional_decimal_from_str"
2226    )]
2227    pub withdrawable: Option<Decimal>,
2228    /// Time of the state snapshot (milliseconds since epoch).
2229    #[serde(default)]
2230    pub time: Option<u64>,
2231}
2232
2233/// A single asset position in the clearinghouse state.
2234#[derive(Debug, Clone, Serialize, Deserialize)]
2235#[serde(rename_all = "camelCase")]
2236pub struct AssetPosition {
2237    /// Position information.
2238    pub position: PositionData,
2239    /// Type of position.
2240    #[serde(rename = "type")]
2241    pub position_type: HyperliquidPositionType,
2242}
2243
2244/// Leverage information for a position.
2245#[derive(Debug, Clone, Serialize, Deserialize)]
2246#[serde(rename_all = "camelCase")]
2247pub struct LeverageInfo {
2248    #[serde(rename = "type")]
2249    pub leverage_type: HyperliquidLeverageType,
2250    /// Leverage value.
2251    pub value: u32,
2252}
2253
2254/// Cumulative funding breakdown for a position.
2255#[derive(Debug, Clone, Serialize, Deserialize)]
2256#[serde(rename_all = "camelCase")]
2257pub struct CumFundingInfo {
2258    /// All-time cumulative funding.
2259    #[serde(
2260        rename = "allTime",
2261        serialize_with = "serialize_decimal_as_str",
2262        deserialize_with = "deserialize_decimal_from_str"
2263    )]
2264    pub all_time: Decimal,
2265    /// Funding since position opened.
2266    #[serde(
2267        rename = "sinceOpen",
2268        serialize_with = "serialize_decimal_as_str",
2269        deserialize_with = "deserialize_decimal_from_str"
2270    )]
2271    pub since_open: Decimal,
2272    /// Funding since last position change.
2273    #[serde(
2274        rename = "sinceChange",
2275        serialize_with = "serialize_decimal_as_str",
2276        deserialize_with = "deserialize_decimal_from_str"
2277    )]
2278    pub since_change: Decimal,
2279}
2280
2281/// Detailed position data for an asset.
2282#[derive(Debug, Clone, Serialize, Deserialize)]
2283#[serde(rename_all = "camelCase")]
2284pub struct PositionData {
2285    /// Asset symbol/coin (e.g., "BTC").
2286    pub coin: Ustr,
2287    /// Cumulative funding breakdown.
2288    #[serde(rename = "cumFunding")]
2289    pub cum_funding: CumFundingInfo,
2290    /// Entry price for the position.
2291    #[serde(
2292        rename = "entryPx",
2293        serialize_with = "serialize_optional_decimal_as_str",
2294        deserialize_with = "deserialize_optional_decimal_from_str",
2295        default
2296    )]
2297    pub entry_px: Option<Decimal>,
2298    /// Leverage information for the position.
2299    pub leverage: LeverageInfo,
2300    /// Liquidation price.
2301    #[serde(
2302        rename = "liquidationPx",
2303        serialize_with = "serialize_optional_decimal_as_str",
2304        deserialize_with = "deserialize_optional_decimal_from_str",
2305        default
2306    )]
2307    pub liquidation_px: Option<Decimal>,
2308    /// Margin used for this position.
2309    #[serde(
2310        rename = "marginUsed",
2311        serialize_with = "serialize_decimal_as_str",
2312        deserialize_with = "deserialize_decimal_from_str"
2313    )]
2314    pub margin_used: Decimal,
2315    /// Maximum leverage allowed for this asset.
2316    #[serde(rename = "maxLeverage", default)]
2317    pub max_leverage: Option<u32>,
2318    /// Position value.
2319    #[serde(
2320        rename = "positionValue",
2321        serialize_with = "serialize_decimal_as_str",
2322        deserialize_with = "deserialize_decimal_from_str"
2323    )]
2324    pub position_value: Decimal,
2325    /// Return on equity percentage.
2326    #[serde(
2327        rename = "returnOnEquity",
2328        serialize_with = "serialize_decimal_as_str",
2329        deserialize_with = "deserialize_decimal_from_str"
2330    )]
2331    pub return_on_equity: Decimal,
2332    /// Position size (positive for long, negative for short).
2333    #[serde(
2334        rename = "szi",
2335        serialize_with = "serialize_decimal_as_str",
2336        deserialize_with = "deserialize_decimal_from_str"
2337    )]
2338    pub szi: Decimal,
2339    /// Unrealized PnL.
2340    #[serde(
2341        rename = "unrealizedPnl",
2342        serialize_with = "serialize_decimal_as_str",
2343        deserialize_with = "deserialize_decimal_from_str"
2344    )]
2345    pub unrealized_pnl: Decimal,
2346}
2347
2348/// Complete spot clearinghouse state response from `POST /info`
2349/// with `{ "type": "spotClearinghouseState", "user": "address" }`.
2350///
2351/// Provides per-token spot balances for the queried address. Under unified or
2352/// portfolio margin accounts this is the source of truth for spot holdings.
2353#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2354#[serde(rename_all = "camelCase")]
2355pub struct SpotClearinghouseState {
2356    /// Per-token spot balances.
2357    #[serde(default)]
2358    pub balances: Vec<SpotBalance>,
2359}
2360
2361/// A single token balance entry from `spotClearinghouseState.balances`.
2362#[derive(Debug, Clone, Serialize, Deserialize)]
2363#[serde(rename_all = "camelCase")]
2364pub struct SpotBalance {
2365    /// Token name (e.g., "USDC", "PURR").
2366    pub coin: Ustr,
2367    /// Token index matching `spotMeta.tokens[*].index`. Omitted by the venue
2368    /// for HIP-4 outcome side tokens (`+E` coins).
2369    #[serde(default)]
2370    pub token: Option<u32>,
2371    /// Total token balance (on-hold plus available).
2372    #[serde(
2373        serialize_with = "serialize_decimal_as_str",
2374        deserialize_with = "deserialize_decimal_from_str"
2375    )]
2376    pub total: Decimal,
2377    /// Portion currently reserved for resting orders.
2378    #[serde(
2379        serialize_with = "serialize_decimal_as_str",
2380        deserialize_with = "deserialize_decimal_from_str"
2381    )]
2382    pub hold: Decimal,
2383    /// Entry notional value (position cost basis in USDC).
2384    #[serde(
2385        default,
2386        serialize_with = "serialize_optional_decimal_as_str",
2387        deserialize_with = "deserialize_optional_decimal_from_str"
2388    )]
2389    pub entry_ntl: Option<Decimal>,
2390}
2391
2392impl SpotBalance {
2393    /// Returns the balance freely available to trade or withdraw (`total - hold`).
2394    #[must_use]
2395    pub fn free(&self) -> Decimal {
2396        (self.total - self.hold).max(Decimal::ZERO)
2397    }
2398
2399    /// Returns the average entry price derived from `entry_ntl / total`, if both are non-zero.
2400    #[must_use]
2401    pub fn avg_entry_px(&self) -> Option<Decimal> {
2402        let entry_ntl = self.entry_ntl?;
2403
2404        if entry_ntl.is_zero() || self.total.is_zero() {
2405            return None;
2406        }
2407
2408        Some(entry_ntl / self.total)
2409    }
2410}
2411
2412/// Cross margin summary information.
2413#[derive(Debug, Clone, Serialize, Deserialize)]
2414#[serde(rename_all = "camelCase")]
2415pub struct CrossMarginSummary {
2416    /// Account value in USD.
2417    #[serde(
2418        rename = "accountValue",
2419        serialize_with = "serialize_decimal_as_str",
2420        deserialize_with = "deserialize_decimal_from_str"
2421    )]
2422    pub account_value: Decimal,
2423    /// Total notional position value.
2424    #[serde(
2425        rename = "totalNtlPos",
2426        serialize_with = "serialize_decimal_as_str",
2427        deserialize_with = "deserialize_decimal_from_str"
2428    )]
2429    pub total_ntl_pos: Decimal,
2430    /// Total raw USD value (collateral).
2431    #[serde(
2432        rename = "totalRawUsd",
2433        serialize_with = "serialize_decimal_as_str",
2434        deserialize_with = "deserialize_decimal_from_str"
2435    )]
2436    pub total_raw_usd: Decimal,
2437    /// Total margin used across all positions.
2438    #[serde(
2439        rename = "totalMarginUsed",
2440        serialize_with = "serialize_decimal_as_str",
2441        deserialize_with = "deserialize_decimal_from_str"
2442    )]
2443    pub total_margin_used: Decimal,
2444    /// Withdrawable balance.
2445    #[serde(
2446        rename = "withdrawable",
2447        default,
2448        serialize_with = "serialize_optional_decimal_as_str",
2449        deserialize_with = "deserialize_optional_decimal_from_str"
2450    )]
2451    pub withdrawable: Option<Decimal>,
2452}
2453
2454fn serialize_order_decimal_as_str<S>(decimal: &Decimal, serializer: S) -> Result<S::Ok, S::Error>
2455where
2456    S: Serializer,
2457{
2458    serialize_decimal_as_str(&decimal.normalize(), serializer)
2459}