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