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