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 = HyperliquidExecAction::Order {
1143            orders: vec![],
1144            grouping: HyperliquidExecGrouping::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 = HyperliquidExecAction::Cancel {
1181            cancels: vec![HyperliquidExecCancelOrderRequest {
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 = HyperliquidExecAction::CancelByCloid {
1203            cancels: vec![HyperliquidExecCancelByCloidRequest {
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: HyperliquidExecOrderResponseData = serde_json::from_str(json).unwrap();
1239        assert_eq!(data.statuses.len(), 3);
1240
1241        assert!(matches!(
1242            data.statuses[0],
1243            HyperliquidExecOrderStatus::Resting { ref resting } if resting.oid == 446050656712
1244        ));
1245        assert!(matches!(
1246            data.statuses[1],
1247            HyperliquidExecOrderStatus::Tag(HyperliquidExecOrderStatusTag::WaitingForFill)
1248        ));
1249        assert!(matches!(
1250            data.statuses[2],
1251            HyperliquidExecOrderStatus::Tag(HyperliquidExecOrderStatusTag::WaitingForTrigger)
1252        ));
1253    }
1254
1255    #[rstest]
1256    fn test_user_outcome_split_serialization() {
1257        let action = HyperliquidExecAction::UserOutcome {
1258            op: HyperliquidExecUserOutcomeOp::SplitOutcome(HyperliquidExecSplitOutcomeParams {
1259                outcome: 1,
1260                amount: dec!(123.0),
1261            }),
1262        };
1263
1264        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1265        assert_eq!(
1266            value,
1267            json!({
1268                "type": "userOutcome",
1269                "splitOutcome": { "outcome": 1, "amount": "123.0" }
1270            })
1271        );
1272    }
1273
1274    #[rstest]
1275    fn test_user_outcome_split_msgpack_roundtrip() {
1276        let action = HyperliquidExecAction::UserOutcome {
1277            op: HyperliquidExecUserOutcomeOp::SplitOutcome(HyperliquidExecSplitOutcomeParams {
1278                outcome: 4,
1279                amount: dec!(10),
1280            }),
1281        };
1282
1283        let bytes = rmp_serde::to_vec_named(&action).unwrap();
1284        let decoded: serde_json::Value = rmp_serde::from_slice(&bytes).unwrap();
1285        assert_eq!(
1286            decoded,
1287            json!({
1288                "type": "userOutcome",
1289                "splitOutcome": { "outcome": 4, "amount": "10" }
1290            })
1291        );
1292    }
1293
1294    #[rstest]
1295    fn test_hyperliquid_level_serializes_decimals_as_strings() {
1296        // Decimal fields must serialize back to the string wire form, not a
1297        // JSON number.
1298        let level = HyperliquidLevel {
1299            px: dec!(98450.5),
1300            sz: dec!(2.5),
1301        };
1302        let value = serde_json::to_value(&level).unwrap();
1303        assert_eq!(value, json!({ "px": "98450.5", "sz": "2.5" }));
1304    }
1305
1306    #[rstest]
1307    fn test_user_outcome_merge_outcome_serialization() {
1308        let action = HyperliquidExecAction::UserOutcome {
1309            op: HyperliquidExecUserOutcomeOp::MergeOutcome(HyperliquidExecMergeOutcomeParams {
1310                outcome: 1,
1311                amount: Some(dec!(5.0)),
1312            }),
1313        };
1314        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1315        assert_eq!(
1316            value,
1317            json!({
1318                "type": "userOutcome",
1319                "mergeOutcome": { "outcome": 1, "amount": "5.0" }
1320            })
1321        );
1322    }
1323
1324    #[rstest]
1325    fn test_user_outcome_merge_outcome_null_amount_means_max() {
1326        let action = HyperliquidExecAction::UserOutcome {
1327            op: HyperliquidExecUserOutcomeOp::MergeOutcome(HyperliquidExecMergeOutcomeParams {
1328                outcome: 7,
1329                amount: None,
1330            }),
1331        };
1332        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1333        assert_eq!(
1334            value,
1335            json!({
1336                "type": "userOutcome",
1337                "mergeOutcome": { "outcome": 7, "amount": null }
1338            })
1339        );
1340    }
1341
1342    #[rstest]
1343    fn test_user_outcome_merge_question_serialization() {
1344        let action = HyperliquidExecAction::UserOutcome {
1345            op: HyperliquidExecUserOutcomeOp::MergeQuestion(HyperliquidExecMergeQuestionParams {
1346                question: 9,
1347                amount: Some(dec!(2.0)),
1348            }),
1349        };
1350        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1351        assert_eq!(
1352            value,
1353            json!({
1354                "type": "userOutcome",
1355                "mergeQuestion": { "question": 9, "amount": "2.0" }
1356            })
1357        );
1358    }
1359
1360    #[rstest]
1361    fn test_user_outcome_merge_question_null_amount_means_max() {
1362        let action = HyperliquidExecAction::UserOutcome {
1363            op: HyperliquidExecUserOutcomeOp::MergeQuestion(HyperliquidExecMergeQuestionParams {
1364                question: 9,
1365                amount: None,
1366            }),
1367        };
1368        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1369        assert_eq!(
1370            value,
1371            json!({
1372                "type": "userOutcome",
1373                "mergeQuestion": { "question": 9, "amount": null }
1374            })
1375        );
1376    }
1377
1378    #[rstest]
1379    fn test_user_outcome_negate_outcome_serialization() {
1380        let action = HyperliquidExecAction::UserOutcome {
1381            op: HyperliquidExecUserOutcomeOp::NegateOutcome(HyperliquidExecNegateOutcomeParams {
1382                question: 9,
1383                outcome: 52,
1384                amount: dec!(1.5),
1385            }),
1386        };
1387        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1388        assert_eq!(
1389            value,
1390            json!({
1391                "type": "userOutcome",
1392                "negateOutcome": { "question": 9, "outcome": 52, "amount": "1.5" }
1393            })
1394        );
1395    }
1396
1397    #[rstest]
1398    fn test_modify_target_serializes_numeric_oid() {
1399        let request = modify_request_with_target(HyperliquidExecModifyTarget::Oid(12345));
1400        let value: serde_json::Value = serde_json::to_value(request).unwrap();
1401
1402        assert_eq!(value["oid"], json!(12345));
1403    }
1404
1405    #[rstest]
1406    fn test_modify_target_serializes_cloid() {
1407        let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
1408        let request = modify_request_with_target(HyperliquidExecModifyTarget::Cloid(cloid));
1409        let value: serde_json::Value = serde_json::to_value(request).unwrap();
1410
1411        assert_eq!(value["oid"], json!("0x1234567890abcdef1234567890abcdef"));
1412    }
1413
1414    fn modify_request_with_target(
1415        oid: HyperliquidExecModifyTarget,
1416    ) -> HyperliquidExecModifyOrderRequest {
1417        HyperliquidExecModifyOrderRequest {
1418            oid,
1419            order: HyperliquidExecPlaceOrderRequest {
1420                asset: 0,
1421                is_buy: true,
1422                price: dec!(51000),
1423                size: dec!(0.2),
1424                reduce_only: false,
1425                kind: HyperliquidExecOrderKind::Limit {
1426                    limit: HyperliquidExecLimitParams {
1427                        tif: HyperliquidExecTif::Gtc,
1428                    },
1429                },
1430                cloid: None,
1431            },
1432        }
1433    }
1434}
1435
1436/// Time-in-force for limit orders in exchange endpoint.
1437///
1438/// These values must match exactly what Hyperliquid expects for proper serialization.
1439#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1440pub enum HyperliquidExecTif {
1441    /// Add Liquidity Only (post-only order).
1442    #[serde(rename = "Alo")]
1443    Alo,
1444    /// Immediate or Cancel.
1445    #[serde(rename = "Ioc")]
1446    Ioc,
1447    /// Good Till Canceled.
1448    #[serde(rename = "Gtc")]
1449    Gtc,
1450}
1451
1452/// Take profit or stop loss side for trigger orders in exchange endpoint.
1453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1454pub enum HyperliquidExecTpSl {
1455    /// Take profit.
1456    #[serde(rename = "tp")]
1457    Tp,
1458    /// Stop loss.
1459    #[serde(rename = "sl")]
1460    Sl,
1461}
1462
1463/// Order grouping strategy for linked TP/SL orders in exchange endpoint.
1464#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1465pub enum HyperliquidExecGrouping {
1466    /// No grouping semantics.
1467    #[serde(rename = "na")]
1468    #[default]
1469    Na,
1470    /// Normal TP/SL grouping (linked orders).
1471    #[serde(rename = "normalTpsl")]
1472    NormalTpsl,
1473    /// Position-level TP/SL grouping.
1474    #[serde(rename = "positionTpsl")]
1475    PositionTpsl,
1476}
1477
1478/// Order kind specification for the `t` field in exchange endpoint order requests.
1479#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1480#[serde(untagged)]
1481pub enum HyperliquidExecOrderKind {
1482    /// Limit order with time-in-force.
1483    Limit {
1484        /// Limit order parameters.
1485        limit: HyperliquidExecLimitParams,
1486    },
1487    /// Trigger order (stop/take profit).
1488    Trigger {
1489        /// Trigger order parameters.
1490        trigger: HyperliquidExecTriggerParams,
1491    },
1492}
1493
1494/// Parameters for limit orders in exchange endpoint.
1495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1496pub struct HyperliquidExecLimitParams {
1497    /// Time-in-force for the limit order.
1498    pub tif: HyperliquidExecTif,
1499}
1500
1501/// Parameters for trigger orders (stop/take profit) in exchange endpoint.
1502#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1503#[serde(rename_all = "camelCase")]
1504pub struct HyperliquidExecTriggerParams {
1505    /// Whether to use market price when triggered.
1506    pub is_market: bool,
1507    /// Trigger price as a string.
1508    #[serde(
1509        serialize_with = "serialize_decimal_as_str",
1510        deserialize_with = "deserialize_decimal_from_str"
1511    )]
1512    pub trigger_px: Decimal,
1513    /// Whether this is a take profit or stop loss.
1514    pub tpsl: HyperliquidExecTpSl,
1515}
1516
1517/// Builder code for order attribution in the exchange endpoint.
1518///
1519/// The fee is specified in tenths of a basis point.
1520/// For example, `f: 10` represents 1 basis point (0.01%).
1521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1522pub struct HyperliquidExecBuilderFee {
1523    /// Builder address for attribution.
1524    #[serde(rename = "b")]
1525    pub address: String,
1526    /// Fee in tenths of a basis point.
1527    #[serde(rename = "f")]
1528    pub fee_tenths_bp: u32,
1529}
1530
1531/// Order specification for placing orders via exchange endpoint.
1532///
1533/// This struct represents a single order in the exact format expected
1534/// by the Hyperliquid exchange endpoint.
1535#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1536pub struct HyperliquidExecPlaceOrderRequest {
1537    /// Asset ID.
1538    #[serde(rename = "a")]
1539    pub asset: AssetId,
1540    /// Is buy order (true for buy, false for sell).
1541    #[serde(rename = "b")]
1542    pub is_buy: bool,
1543    /// Price as a string with no trailing zeros.
1544    #[serde(
1545        rename = "p",
1546        serialize_with = "serialize_decimal_as_str",
1547        deserialize_with = "deserialize_decimal_from_str"
1548    )]
1549    pub price: Decimal,
1550    /// Size as a string with no trailing zeros.
1551    #[serde(
1552        rename = "s",
1553        serialize_with = "serialize_decimal_as_str",
1554        deserialize_with = "deserialize_decimal_from_str"
1555    )]
1556    pub size: Decimal,
1557    /// Reduce-only flag.
1558    #[serde(rename = "r")]
1559    pub reduce_only: bool,
1560    /// Order type (limit or trigger).
1561    #[serde(rename = "t")]
1562    pub kind: HyperliquidExecOrderKind,
1563    /// Optional client order ID (128-bit hex).
1564    #[serde(rename = "c", skip_serializing_if = "Option::is_none")]
1565    pub cloid: Option<Cloid>,
1566}
1567
1568/// Cancel specification for canceling orders by order ID via exchange endpoint.
1569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1570pub struct HyperliquidExecCancelOrderRequest {
1571    /// Asset ID.
1572    #[serde(rename = "a")]
1573    pub asset: AssetId,
1574    /// Order ID to cancel.
1575    #[serde(rename = "o")]
1576    pub oid: OrderId,
1577}
1578
1579/// Cancel specification for canceling orders by client order ID via exchange endpoint.
1580///
1581/// Note: Unlike order placement which uses abbreviated field names ("a", "c"),
1582/// cancel-by-cloid uses full field names ("asset", "cloid") per the Hyperliquid API.
1583#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1584pub struct HyperliquidExecCancelByCloidRequest {
1585    /// Asset ID.
1586    pub asset: AssetId,
1587    /// Client order ID to cancel.
1588    pub cloid: Cloid,
1589}
1590
1591/// Target of a modify request.
1592///
1593/// Hyperliquid names this field `oid`, but accepts either a numeric venue
1594/// order ID or a CLOID.
1595#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1596#[serde(untagged)]
1597pub enum HyperliquidExecModifyTarget {
1598    /// Numeric venue order ID.
1599    Oid(OrderId),
1600    /// CLOID.
1601    Cloid(Cloid),
1602}
1603
1604impl HyperliquidExecModifyTarget {
1605    /// Creates a numeric modify target from a Nautilus venue order ID.
1606    ///
1607    /// # Errors
1608    ///
1609    /// Returns an error if the venue order ID is not a numeric Hyperliquid order ID.
1610    pub fn from_venue_order_id(
1611        venue_order_id: &VenueOrderId,
1612    ) -> Result<Self, std::num::ParseIntError> {
1613        venue_order_id.as_str().parse::<OrderId>().map(Self::Oid)
1614    }
1615}
1616
1617impl From<OrderId> for HyperliquidExecModifyTarget {
1618    fn from(value: OrderId) -> Self {
1619        Self::Oid(value)
1620    }
1621}
1622
1623impl From<Cloid> for HyperliquidExecModifyTarget {
1624    fn from(value: Cloid) -> Self {
1625        Self::Cloid(value)
1626    }
1627}
1628
1629/// Modify specification for modifying existing orders via exchange endpoint.
1630///
1631/// The HL API requires the full order spec (same as a place order) plus
1632/// the venue order ID or CLOID to modify.
1633#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1634pub struct HyperliquidExecModifyOrderRequest {
1635    /// Venue order ID or CLOID to modify.
1636    pub oid: HyperliquidExecModifyTarget,
1637    /// Full replacement order specification.
1638    pub order: HyperliquidExecPlaceOrderRequest,
1639}
1640
1641/// Parameters for the HIP-4 `splitOutcome` operation inside a `userOutcome` action.
1642///
1643/// Debits `amount` quote tokens from the user's spot balance and credits both
1644/// the Yes and No side tokens of the referenced outcome.
1645#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1646pub struct HyperliquidExecSplitOutcomeParams {
1647    /// Outcome index (matches `outcomeMeta.outcomes[i].outcome`).
1648    pub outcome: u32,
1649    /// Quote-token amount to split, serialized as a decimal string (e.g. `"123.0"`).
1650    #[serde(
1651        serialize_with = "serialize_decimal_as_str",
1652        deserialize_with = "deserialize_decimal_from_str"
1653    )]
1654    pub amount: Decimal,
1655}
1656
1657/// Parameters for the HIP-4 `mergeOutcome` operation inside a `userOutcome` action.
1658///
1659/// Burns `amount` matched Yes + No side tokens of `outcome` for `amount` quote
1660/// tokens back. `amount = None` serializes as `null`, which the venue treats as
1661/// the maximum mergeable balance.
1662#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1663pub struct HyperliquidExecMergeOutcomeParams {
1664    /// Outcome index whose Yes + No pair is being merged.
1665    pub outcome: u32,
1666    /// Side-token amount to merge, or `None` to merge the maximum available.
1667    #[serde(
1668        default,
1669        serialize_with = "serialize_optional_decimal_as_str",
1670        deserialize_with = "deserialize_optional_decimal_from_str"
1671    )]
1672    pub amount: Option<Decimal>,
1673}
1674
1675/// Parameters for the HIP-4 `mergeQuestion` operation inside a `userOutcome` action.
1676///
1677/// Burns `amount` Yes shares of every outcome associated with `question` for
1678/// `amount` quote tokens back. `amount = None` serializes as `null`, meaning
1679/// the maximum mergeable balance.
1680#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1681pub struct HyperliquidExecMergeQuestionParams {
1682    /// Question identifier whose named outcomes are being merged.
1683    pub question: u32,
1684    /// Yes-share amount to merge per outcome, or `None` for the max.
1685    #[serde(
1686        default,
1687        serialize_with = "serialize_optional_decimal_as_str",
1688        deserialize_with = "deserialize_optional_decimal_from_str"
1689    )]
1690    pub amount: Option<Decimal>,
1691}
1692
1693/// Parameters for the HIP-4 `negateOutcome` operation inside a `userOutcome` action.
1694///
1695/// Converts `amount` `No` shares of `outcome` (within `question`) into `amount`
1696/// `Yes` shares of every other outcome in the same question.
1697#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1698pub struct HyperliquidExecNegateOutcomeParams {
1699    /// Question identifier the outcome belongs to.
1700    pub question: u32,
1701    /// Outcome index whose `No` shares are being negated.
1702    pub outcome: u32,
1703    /// Side-token amount to negate, serialized as a decimal string.
1704    #[serde(
1705        serialize_with = "serialize_decimal_as_str",
1706        deserialize_with = "deserialize_decimal_from_str"
1707    )]
1708    pub amount: Decimal,
1709}
1710
1711/// Operations carried by the [`HyperliquidExecAction::UserOutcome`] action.
1712///
1713/// Each variant serializes as a single-keyed object (for example,
1714/// `{ "splitOutcome": { ... } }`) and is flattened into the outer action
1715/// envelope alongside `"type": "userOutcome"` to match the Hyperliquid wire
1716/// format.
1717#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1718pub enum HyperliquidExecUserOutcomeOp {
1719    /// Split `amount` quote tokens into `amount` Yes plus `amount` No shares.
1720    #[serde(rename = "splitOutcome")]
1721    SplitOutcome(HyperliquidExecSplitOutcomeParams),
1722    /// Merge `amount` Yes + No side-token pairs of `outcome` back into quote
1723    /// tokens (reverse of [`Self::SplitOutcome`]).
1724    #[serde(rename = "mergeOutcome")]
1725    MergeOutcome(HyperliquidExecMergeOutcomeParams),
1726    /// Merge `amount` Yes shares of every outcome in `question` into quote
1727    /// tokens (multi-outcome reverse of `splitOutcome`).
1728    #[serde(rename = "mergeQuestion")]
1729    MergeQuestion(HyperliquidExecMergeQuestionParams),
1730    /// Swap `amount` `No` shares of one outcome into `Yes` shares of every
1731    /// other outcome in the same question.
1732    #[serde(rename = "negateOutcome")]
1733    NegateOutcome(HyperliquidExecNegateOutcomeParams),
1734}
1735
1736/// TWAP (Time-Weighted Average Price) order specification for exchange endpoint.
1737#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1738pub struct HyperliquidExecTwapRequest {
1739    /// Asset ID.
1740    #[serde(rename = "a")]
1741    pub asset: AssetId,
1742    /// Is buy order.
1743    #[serde(rename = "b")]
1744    pub is_buy: bool,
1745    /// Total size to execute.
1746    #[serde(
1747        rename = "s",
1748        serialize_with = "serialize_decimal_as_str",
1749        deserialize_with = "deserialize_decimal_from_str"
1750    )]
1751    pub size: Decimal,
1752    /// Duration in milliseconds.
1753    #[serde(rename = "m")]
1754    pub duration_ms: u64,
1755}
1756
1757/// All possible exchange actions for the Hyperliquid `/exchange` endpoint.
1758///
1759/// Each variant corresponds to a specific action type that can be performed
1760/// through the exchange API. The serialization uses the exact action type
1761/// names expected by Hyperliquid.
1762#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1763#[serde(tag = "type")]
1764pub enum HyperliquidExecAction {
1765    /// Place one or more orders.
1766    #[serde(rename = "order")]
1767    Order {
1768        /// List of orders to place.
1769        orders: Vec<HyperliquidExecPlaceOrderRequest>,
1770        /// Grouping strategy for TP/SL orders.
1771        #[serde(default)]
1772        grouping: HyperliquidExecGrouping,
1773        /// Optional builder code for attribution.
1774        #[serde(skip_serializing_if = "Option::is_none")]
1775        builder: Option<HyperliquidExecBuilderFee>,
1776    },
1777
1778    /// Cancel orders by order ID.
1779    #[serde(rename = "cancel")]
1780    Cancel {
1781        /// Orders to cancel.
1782        cancels: Vec<HyperliquidExecCancelOrderRequest>,
1783        /// Optional fast-cancel flag.
1784        #[serde(rename = "f", skip_serializing_if = "Option::is_none")]
1785        fast: Option<bool>,
1786    },
1787
1788    /// Cancel orders by client order ID.
1789    #[serde(rename = "cancelByCloid")]
1790    CancelByCloid {
1791        /// Orders to cancel by CLOID.
1792        cancels: Vec<HyperliquidExecCancelByCloidRequest>,
1793        /// Optional fast-cancel flag.
1794        #[serde(rename = "f", skip_serializing_if = "Option::is_none")]
1795        fast: Option<bool>,
1796    },
1797
1798    /// Modify a single order.
1799    #[serde(rename = "modify")]
1800    Modify {
1801        /// Order modification specification.
1802        #[serde(flatten)]
1803        modify: HyperliquidExecModifyOrderRequest,
1804    },
1805
1806    /// Modify multiple orders atomically.
1807    #[serde(rename = "batchModify")]
1808    BatchModify {
1809        /// Multiple order modifications.
1810        modifies: Vec<HyperliquidExecModifyOrderRequest>,
1811    },
1812
1813    /// Schedule automatic order cancellation (dead man's switch).
1814    #[serde(rename = "scheduleCancel")]
1815    ScheduleCancel {
1816        /// Time in milliseconds when orders should be cancelled.
1817        /// If None, clears the existing schedule.
1818        #[serde(skip_serializing_if = "Option::is_none")]
1819        time: Option<u64>,
1820    },
1821
1822    /// Update leverage for a position.
1823    #[serde(rename = "updateLeverage")]
1824    UpdateLeverage {
1825        /// Asset ID.
1826        #[serde(rename = "a")]
1827        asset: AssetId,
1828        /// Whether to use cross margin.
1829        #[serde(rename = "isCross")]
1830        is_cross: bool,
1831        /// Leverage value.
1832        #[serde(rename = "leverage")]
1833        leverage: u32,
1834    },
1835
1836    /// Update isolated margin for a position.
1837    #[serde(rename = "updateIsolatedMargin")]
1838    UpdateIsolatedMargin {
1839        /// Asset ID.
1840        #[serde(rename = "a")]
1841        asset: AssetId,
1842        /// Margin delta as a string.
1843        #[serde(
1844            rename = "delta",
1845            serialize_with = "serialize_decimal_as_str",
1846            deserialize_with = "deserialize_decimal_from_str"
1847        )]
1848        delta: Decimal,
1849    },
1850
1851    /// Transfer USD between spot and perp accounts.
1852    #[serde(rename = "usdClassTransfer")]
1853    UsdClassTransfer {
1854        /// Source account type.
1855        from: String,
1856        /// Destination account type.
1857        to: String,
1858        /// Amount to transfer.
1859        #[serde(
1860            serialize_with = "serialize_decimal_as_str",
1861            deserialize_with = "deserialize_decimal_from_str"
1862        )]
1863        amount: Decimal,
1864    },
1865
1866    /// HIP-4 outcome-side token management (`splitOutcome` and related ops).
1867    ///
1868    /// The active op is carried via [`HyperliquidExecUserOutcomeOp`] and
1869    /// flattened into this action envelope, producing wire payloads such as
1870    /// `{ "type": "userOutcome", "splitOutcome": { ... } }`.
1871    #[serde(rename = "userOutcome")]
1872    UserOutcome {
1873        /// Operation to perform on the user's outcome balances.
1874        #[serde(flatten)]
1875        op: HyperliquidExecUserOutcomeOp,
1876    },
1877
1878    /// Place a TWAP order.
1879    #[serde(rename = "twapPlace")]
1880    TwapPlace {
1881        /// TWAP order specification.
1882        #[serde(flatten)]
1883        twap: HyperliquidExecTwapRequest,
1884    },
1885
1886    /// Cancel a TWAP order.
1887    #[serde(rename = "twapCancel")]
1888    TwapCancel {
1889        /// Asset ID.
1890        #[serde(rename = "a")]
1891        asset: AssetId,
1892        /// TWAP ID.
1893        #[serde(rename = "t")]
1894        twap_id: u64,
1895    },
1896
1897    /// No-operation to invalidate pending nonces.
1898    #[serde(rename = "noop")]
1899    Noop,
1900}
1901
1902/// Exchange request envelope for the `/exchange` endpoint.
1903///
1904/// This is the top-level structure sent to Hyperliquid's exchange endpoint.
1905/// It includes the action to perform along with authentication and metadata.
1906#[derive(Debug, Clone, Serialize)]
1907#[serde(rename_all = "camelCase")]
1908pub struct HyperliquidExecRequest {
1909    /// The exchange action to perform.
1910    pub action: HyperliquidExecAction,
1911    /// Request nonce for replay protection (milliseconds timestamp recommended).
1912    pub nonce: u64,
1913    /// ECC signature over the action and nonce.
1914    pub signature: String,
1915    /// Optional vault address for sub-account trading.
1916    #[serde(skip_serializing_if = "Option::is_none")]
1917    pub vault_address: Option<String>,
1918    /// Optional expiration time in milliseconds.
1919    /// Note: Using this field increases rate limit weight by 5x if the request expires.
1920    #[serde(skip_serializing_if = "Option::is_none")]
1921    pub expires_after: Option<u64>,
1922}
1923
1924/// Exchange response envelope from the `/exchange` endpoint.
1925#[derive(Debug, Clone, Serialize, Deserialize)]
1926pub struct HyperliquidExecResponse {
1927    /// Response status ("ok" for success).
1928    pub status: String,
1929    /// Response payload.
1930    pub response: HyperliquidExecResponseData,
1931}
1932
1933/// Response data containing the actual response payload from exchange endpoint.
1934#[derive(Debug, Clone, Serialize, Deserialize)]
1935#[serde(tag = "type")]
1936pub enum HyperliquidExecResponseData {
1937    /// Response for order actions.
1938    #[serde(rename = "order")]
1939    Order {
1940        /// Order response data.
1941        data: HyperliquidExecOrderResponseData,
1942    },
1943    /// Response for cancel actions.
1944    #[serde(rename = "cancel")]
1945    Cancel {
1946        /// Cancel response data.
1947        data: HyperliquidExecCancelResponseData,
1948    },
1949    /// Response for modify actions.
1950    #[serde(rename = "modify")]
1951    Modify {
1952        /// Modify response data.
1953        data: HyperliquidExecModifyResponseData,
1954    },
1955    /// Generic response for other actions.
1956    #[serde(rename = "default")]
1957    Default,
1958    /// Catch-all for unknown response types.
1959    #[serde(other)]
1960    Unknown,
1961}
1962
1963/// Order response data containing status for each order from exchange endpoint.
1964#[derive(Debug, Clone, Serialize, Deserialize)]
1965pub struct HyperliquidExecOrderResponseData {
1966    /// Status for each order in the request.
1967    pub statuses: Vec<HyperliquidExecOrderStatus>,
1968}
1969
1970/// Cancel response data containing status for each cancellation from exchange endpoint.
1971#[derive(Debug, Clone, Serialize, Deserialize)]
1972pub struct HyperliquidExecCancelResponseData {
1973    /// Status for each cancellation in the request.
1974    pub statuses: Vec<HyperliquidExecCancelStatus>,
1975}
1976
1977/// Modify response data containing status for each modification from exchange endpoint.
1978#[derive(Debug, Clone, Serialize, Deserialize)]
1979pub struct HyperliquidExecModifyResponseData {
1980    /// Status for each modification in the request.
1981    pub statuses: Vec<HyperliquidExecModifyStatus>,
1982}
1983
1984/// Status of an individual order submission via exchange endpoint.
1985#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1986#[serde(untagged)]
1987pub enum HyperliquidExecOrderStatus {
1988    /// Order is resting on the order book.
1989    Resting {
1990        /// Resting order information.
1991        resting: HyperliquidExecRestingInfo,
1992    },
1993    /// Order was filled immediately.
1994    Filled {
1995        /// Fill information.
1996        filled: HyperliquidExecFilledInfo,
1997    },
1998    /// Order submission failed.
1999    Error {
2000        /// Error message.
2001        error: String,
2002    },
2003    /// Bare status string for a trigger child of a `normalTpsl` group (SL/TP),
2004    /// which Hyperliquid serializes as a JSON string rather than an object
2005    /// (for example `"waitingForFill"` or `"waitingForTrigger"`).
2006    Tag(HyperliquidExecOrderStatusTag),
2007}
2008
2009/// Status tags Hyperliquid serializes as a bare JSON string.
2010///
2011/// Trigger children of a `normalTpsl` group, plus standalone trigger orders
2012/// that have not armed yet, fall in this bucket: the venue defers order-id
2013/// assignment until activation.
2014#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2015pub enum HyperliquidExecOrderStatusTag {
2016    /// Trigger child parked until the parent (entry) order fills.
2017    #[serde(rename = "waitingForFill")]
2018    WaitingForFill,
2019    /// Trigger child parked until its trigger price condition is met.
2020    #[serde(rename = "waitingForTrigger")]
2021    WaitingForTrigger,
2022}
2023
2024/// Information about a resting order via exchange endpoint.
2025#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2026pub struct HyperliquidExecRestingInfo {
2027    /// Order ID assigned by Hyperliquid.
2028    pub oid: OrderId,
2029}
2030
2031/// Information about a filled order via exchange endpoint.
2032#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2033pub struct HyperliquidExecFilledInfo {
2034    /// Total filled size.
2035    #[serde(
2036        rename = "totalSz",
2037        serialize_with = "serialize_decimal_as_str",
2038        deserialize_with = "deserialize_decimal_from_str"
2039    )]
2040    pub total_sz: Decimal,
2041    /// Average fill price.
2042    #[serde(
2043        rename = "avgPx",
2044        serialize_with = "serialize_decimal_as_str",
2045        deserialize_with = "deserialize_decimal_from_str"
2046    )]
2047    pub avg_px: Decimal,
2048    /// Order ID.
2049    pub oid: OrderId,
2050}
2051
2052/// Status of an individual order cancellation via exchange endpoint.
2053#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2054#[serde(untagged)]
2055pub enum HyperliquidExecCancelStatus {
2056    /// Cancellation succeeded.
2057    Success(String), // Usually "success"
2058    /// Cancellation failed.
2059    Error {
2060        /// Error message.
2061        error: String,
2062    },
2063}
2064
2065/// Status of an individual order modification via exchange endpoint.
2066#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2067#[serde(untagged)]
2068pub enum HyperliquidExecModifyStatus {
2069    /// Modification succeeded.
2070    Success(String), // Usually "success"
2071    /// Modification failed.
2072    Error {
2073        /// Error message.
2074        error: String,
2075    },
2076}
2077
2078/// Complete clearinghouse state response from `POST /info` with `{ "type": "clearinghouseState", "user": "address" }`.
2079/// This provides account positions, margin information, and balances.
2080#[derive(Debug, Clone, Serialize, Deserialize)]
2081#[serde(rename_all = "camelCase")]
2082pub struct ClearinghouseState {
2083    /// List of asset positions (perpetual contracts).
2084    #[serde(default)]
2085    pub asset_positions: Vec<AssetPosition>,
2086    /// Cross margin summary information.
2087    #[serde(default)]
2088    pub cross_margin_summary: Option<CrossMarginSummary>,
2089    /// Withdrawable balance (top-level field).
2090    #[serde(
2091        default,
2092        serialize_with = "serialize_optional_decimal_as_str",
2093        deserialize_with = "deserialize_optional_decimal_from_str"
2094    )]
2095    pub withdrawable: Option<Decimal>,
2096    /// Time of the state snapshot (milliseconds since epoch).
2097    #[serde(default)]
2098    pub time: Option<u64>,
2099}
2100
2101/// A single asset position in the clearinghouse state.
2102#[derive(Debug, Clone, Serialize, Deserialize)]
2103#[serde(rename_all = "camelCase")]
2104pub struct AssetPosition {
2105    /// Position information.
2106    pub position: PositionData,
2107    /// Type of position.
2108    #[serde(rename = "type")]
2109    pub position_type: HyperliquidPositionType,
2110}
2111
2112/// Leverage information for a position.
2113#[derive(Debug, Clone, Serialize, Deserialize)]
2114#[serde(rename_all = "camelCase")]
2115pub struct LeverageInfo {
2116    #[serde(rename = "type")]
2117    pub leverage_type: HyperliquidLeverageType,
2118    /// Leverage value.
2119    pub value: u32,
2120}
2121
2122/// Cumulative funding breakdown for a position.
2123#[derive(Debug, Clone, Serialize, Deserialize)]
2124#[serde(rename_all = "camelCase")]
2125pub struct CumFundingInfo {
2126    /// All-time cumulative funding.
2127    #[serde(
2128        rename = "allTime",
2129        serialize_with = "serialize_decimal_as_str",
2130        deserialize_with = "deserialize_decimal_from_str"
2131    )]
2132    pub all_time: Decimal,
2133    /// Funding since position opened.
2134    #[serde(
2135        rename = "sinceOpen",
2136        serialize_with = "serialize_decimal_as_str",
2137        deserialize_with = "deserialize_decimal_from_str"
2138    )]
2139    pub since_open: Decimal,
2140    /// Funding since last position change.
2141    #[serde(
2142        rename = "sinceChange",
2143        serialize_with = "serialize_decimal_as_str",
2144        deserialize_with = "deserialize_decimal_from_str"
2145    )]
2146    pub since_change: Decimal,
2147}
2148
2149/// Detailed position data for an asset.
2150#[derive(Debug, Clone, Serialize, Deserialize)]
2151#[serde(rename_all = "camelCase")]
2152pub struct PositionData {
2153    /// Asset symbol/coin (e.g., "BTC").
2154    pub coin: Ustr,
2155    /// Cumulative funding breakdown.
2156    #[serde(rename = "cumFunding")]
2157    pub cum_funding: CumFundingInfo,
2158    /// Entry price for the position.
2159    #[serde(
2160        rename = "entryPx",
2161        serialize_with = "serialize_optional_decimal_as_str",
2162        deserialize_with = "deserialize_optional_decimal_from_str",
2163        default
2164    )]
2165    pub entry_px: Option<Decimal>,
2166    /// Leverage information for the position.
2167    pub leverage: LeverageInfo,
2168    /// Liquidation price.
2169    #[serde(
2170        rename = "liquidationPx",
2171        serialize_with = "serialize_optional_decimal_as_str",
2172        deserialize_with = "deserialize_optional_decimal_from_str",
2173        default
2174    )]
2175    pub liquidation_px: Option<Decimal>,
2176    /// Margin used for this position.
2177    #[serde(
2178        rename = "marginUsed",
2179        serialize_with = "serialize_decimal_as_str",
2180        deserialize_with = "deserialize_decimal_from_str"
2181    )]
2182    pub margin_used: Decimal,
2183    /// Maximum leverage allowed for this asset.
2184    #[serde(rename = "maxLeverage", default)]
2185    pub max_leverage: Option<u32>,
2186    /// Position value.
2187    #[serde(
2188        rename = "positionValue",
2189        serialize_with = "serialize_decimal_as_str",
2190        deserialize_with = "deserialize_decimal_from_str"
2191    )]
2192    pub position_value: Decimal,
2193    /// Return on equity percentage.
2194    #[serde(
2195        rename = "returnOnEquity",
2196        serialize_with = "serialize_decimal_as_str",
2197        deserialize_with = "deserialize_decimal_from_str"
2198    )]
2199    pub return_on_equity: Decimal,
2200    /// Position size (positive for long, negative for short).
2201    #[serde(
2202        rename = "szi",
2203        serialize_with = "serialize_decimal_as_str",
2204        deserialize_with = "deserialize_decimal_from_str"
2205    )]
2206    pub szi: Decimal,
2207    /// Unrealized PnL.
2208    #[serde(
2209        rename = "unrealizedPnl",
2210        serialize_with = "serialize_decimal_as_str",
2211        deserialize_with = "deserialize_decimal_from_str"
2212    )]
2213    pub unrealized_pnl: Decimal,
2214}
2215
2216/// Complete spot clearinghouse state response from `POST /info`
2217/// with `{ "type": "spotClearinghouseState", "user": "address" }`.
2218///
2219/// Provides per-token spot balances for the queried address. Under unified or
2220/// portfolio margin accounts this is the source of truth for spot holdings.
2221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2222#[serde(rename_all = "camelCase")]
2223pub struct SpotClearinghouseState {
2224    /// Per-token spot balances.
2225    #[serde(default)]
2226    pub balances: Vec<SpotBalance>,
2227}
2228
2229/// A single token balance entry from `spotClearinghouseState.balances`.
2230#[derive(Debug, Clone, Serialize, Deserialize)]
2231#[serde(rename_all = "camelCase")]
2232pub struct SpotBalance {
2233    /// Token name (e.g., "USDC", "PURR").
2234    pub coin: Ustr,
2235    /// Token index matching `spotMeta.tokens[*].index`. Omitted by the venue
2236    /// for HIP-4 outcome side tokens (`+E` coins).
2237    #[serde(default)]
2238    pub token: Option<u32>,
2239    /// Total token balance (on-hold plus available).
2240    #[serde(
2241        serialize_with = "serialize_decimal_as_str",
2242        deserialize_with = "deserialize_decimal_from_str"
2243    )]
2244    pub total: Decimal,
2245    /// Portion currently reserved for resting orders.
2246    #[serde(
2247        serialize_with = "serialize_decimal_as_str",
2248        deserialize_with = "deserialize_decimal_from_str"
2249    )]
2250    pub hold: Decimal,
2251    /// Entry notional value (position cost basis in USDC).
2252    #[serde(
2253        default,
2254        serialize_with = "serialize_optional_decimal_as_str",
2255        deserialize_with = "deserialize_optional_decimal_from_str"
2256    )]
2257    pub entry_ntl: Option<Decimal>,
2258}
2259
2260impl SpotBalance {
2261    /// Returns the balance freely available to trade or withdraw (`total - hold`).
2262    #[must_use]
2263    pub fn free(&self) -> Decimal {
2264        (self.total - self.hold).max(Decimal::ZERO)
2265    }
2266
2267    /// Returns the average entry price derived from `entry_ntl / total`, if both are non-zero.
2268    #[must_use]
2269    pub fn avg_entry_px(&self) -> Option<Decimal> {
2270        let entry_ntl = self.entry_ntl?;
2271
2272        if entry_ntl.is_zero() || self.total.is_zero() {
2273            return None;
2274        }
2275
2276        Some(entry_ntl / self.total)
2277    }
2278}
2279
2280/// Cross margin summary information.
2281#[derive(Debug, Clone, Serialize, Deserialize)]
2282#[serde(rename_all = "camelCase")]
2283pub struct CrossMarginSummary {
2284    /// Account value in USD.
2285    #[serde(
2286        rename = "accountValue",
2287        serialize_with = "serialize_decimal_as_str",
2288        deserialize_with = "deserialize_decimal_from_str"
2289    )]
2290    pub account_value: Decimal,
2291    /// Total notional position value.
2292    #[serde(
2293        rename = "totalNtlPos",
2294        serialize_with = "serialize_decimal_as_str",
2295        deserialize_with = "deserialize_decimal_from_str"
2296    )]
2297    pub total_ntl_pos: Decimal,
2298    /// Total raw USD value (collateral).
2299    #[serde(
2300        rename = "totalRawUsd",
2301        serialize_with = "serialize_decimal_as_str",
2302        deserialize_with = "deserialize_decimal_from_str"
2303    )]
2304    pub total_raw_usd: Decimal,
2305    /// Total margin used across all positions.
2306    #[serde(
2307        rename = "totalMarginUsed",
2308        serialize_with = "serialize_decimal_as_str",
2309        deserialize_with = "deserialize_decimal_from_str"
2310    )]
2311    pub total_margin_used: Decimal,
2312    /// Withdrawable balance.
2313    #[serde(
2314        rename = "withdrawable",
2315        default,
2316        serialize_with = "serialize_optional_decimal_as_str",
2317        deserialize_with = "deserialize_optional_decimal_from_str"
2318    )]
2319    pub withdrawable: Option<Decimal>,
2320}