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.
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    /// Token the fee was paid in (e.g. "USDC", "HYPE").
620    #[serde(rename = "feeToken")]
621    pub fee_token: Ustr,
622}
623
624/// Represents order status response from `POST /info` with `type: "orderStatus"`.
625///
626/// The API returns `{"status": "order", "order": {...}}` when the order is known,
627/// or `{"status": "unknownOid"}` when the oid is not found.
628#[derive(Debug, Clone, Serialize, Deserialize)]
629#[serde(tag = "status", rename_all = "camelCase")]
630pub enum HyperliquidOrderStatus {
631    Order { order: HyperliquidOrderStatusEntry },
632    UnknownOid,
633}
634
635impl HyperliquidOrderStatus {
636    /// Consumes the response and returns the inner entry if the order was found.
637    #[must_use]
638    pub fn into_order(self) -> Option<HyperliquidOrderStatusEntry> {
639        match self {
640            Self::Order { order } => Some(order),
641            Self::UnknownOid => None,
642        }
643    }
644}
645
646/// Represents an individual order status entry.
647#[derive(Debug, Clone, Serialize, Deserialize)]
648pub struct HyperliquidOrderStatusEntry {
649    /// Order information.
650    pub order: HyperliquidOrderInfo,
651    /// Current status.
652    pub status: HyperliquidOrderStatusEnum,
653    /// Status timestamp in milliseconds.
654    #[serde(rename = "statusTimestamp")]
655    pub status_timestamp: u64,
656}
657
658/// Represents order information within an order status entry.
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct HyperliquidOrderInfo {
661    /// Coin symbol.
662    pub coin: Ustr,
663    /// Order side (buy/sell).
664    pub side: HyperliquidSide,
665    /// Limit price.
666    #[serde(
667        rename = "limitPx",
668        serialize_with = "serialize_decimal_as_str",
669        deserialize_with = "deserialize_decimal_from_str"
670    )]
671    pub limit_px: Decimal,
672    /// Order size.
673    #[serde(
674        serialize_with = "serialize_decimal_as_str",
675        deserialize_with = "deserialize_decimal_from_str"
676    )]
677    pub sz: Decimal,
678    /// Order ID.
679    pub oid: u64,
680    /// Order timestamp in milliseconds.
681    pub timestamp: u64,
682    /// Original order size.
683    #[serde(
684        rename = "origSz",
685        serialize_with = "serialize_decimal_as_str",
686        deserialize_with = "deserialize_decimal_from_str"
687    )]
688    pub orig_sz: Decimal,
689    /// Optional client order ID (hex representation of the venue CLOID).
690    #[serde(default)]
691    pub cloid: Option<String>,
692    /// Time in force used by the order.
693    #[serde(default)]
694    pub tif: Option<HyperliquidTimeInForce>,
695    /// Whether the order reduces an existing position.
696    #[serde(rename = "reduceOnly", default)]
697    pub reduce_only: Option<bool>,
698    /// Trigger price for conditional orders.
699    #[serde(
700        rename = "triggerPx",
701        default,
702        deserialize_with = "deserialize_optional_decimal_from_str"
703    )]
704    pub trigger_px: Option<Decimal>,
705    /// Venue order type label.
706    #[serde(rename = "orderType", default)]
707    pub order_type: Option<String>,
708}
709
710/// ECC signature components for Hyperliquid exchange requests.
711#[derive(Debug, Clone, Serialize)]
712pub struct HyperliquidSignature {
713    /// R component of the signature.
714    pub r: String,
715    /// S component of the signature.
716    pub s: String,
717    /// V component (recovery ID) of the signature.
718    pub v: u64,
719}
720
721impl HyperliquidSignature {
722    /// Creates a new [`HyperliquidSignature`] from pre-formatted components.
723    #[must_use]
724    pub fn new(r: String, s: String, v: u64) -> Self {
725        Self { r, s, v }
726    }
727
728    /// Formats as Ethereum hex signature: `0x` + r(64) + s(64) + v(2).
729    #[must_use]
730    pub fn to_hex(&self) -> String {
731        let r = self.r.strip_prefix("0x").unwrap_or(&self.r);
732        let s = self.s.strip_prefix("0x").unwrap_or(&self.s);
733        format!("0x{r}{s}{:02x}", self.v)
734    }
735
736    /// Parses a hex signature string (0x + 64 hex r + 64 hex s + 2 hex v) into components.
737    pub fn from_hex(sig_hex: &str) -> Result<Self, String> {
738        let sig_hex = sig_hex.strip_prefix("0x").unwrap_or(sig_hex);
739
740        if sig_hex.len() != 130 {
741            return Err(format!(
742                "Invalid signature length: expected 130 hex chars, was {}",
743                sig_hex.len()
744            ));
745        }
746
747        let r = format!("0x{}", &sig_hex[0..64]);
748        let s = format!("0x{}", &sig_hex[64..128]);
749        let v = u64::from_str_radix(&sig_hex[128..130], 16)
750            .map_err(|e| format!("Failed to parse v component: {e}"))?;
751
752        Ok(Self { r, s, v })
753    }
754}
755
756/// Represents an exchange action request wrapper for `POST /exchange`.
757#[derive(Debug, Clone, Serialize)]
758pub struct HyperliquidExchangeRequest<T> {
759    /// The action to perform.
760    #[serde(rename = "action")]
761    pub action: T,
762    /// Request nonce for replay protection.
763    #[serde(rename = "nonce")]
764    pub nonce: u64,
765    /// ECC signature over the action.
766    #[serde(rename = "signature")]
767    pub signature: HyperliquidSignature,
768    /// Optional vault address for sub-account trading.
769    #[serde(rename = "vaultAddress", skip_serializing_if = "Option::is_none")]
770    pub vault_address: Option<String>,
771    /// Optional expiration time in milliseconds.
772    #[serde(rename = "expiresAfter", skip_serializing_if = "Option::is_none")]
773    pub expires_after: Option<u64>,
774}
775
776impl<T> HyperliquidExchangeRequest<T>
777where
778    T: Serialize,
779{
780    /// Creates a new exchange request with the given action.
781    #[must_use]
782    pub fn new(action: T, nonce: u64, signature: HyperliquidSignature) -> Self {
783        Self {
784            action,
785            nonce,
786            signature,
787            vault_address: None,
788            expires_after: None,
789        }
790    }
791
792    /// Creates a new exchange request with vault address for sub-account trading.
793    #[must_use]
794    pub fn with_vault(
795        action: T,
796        nonce: u64,
797        signature: HyperliquidSignature,
798        vault_address: String,
799    ) -> Self {
800        Self {
801            action,
802            nonce,
803            signature,
804            vault_address: Some(vault_address),
805            expires_after: None,
806        }
807    }
808
809    /// Convert to JSON value for signing purposes.
810    pub fn to_sign_value(&self) -> serde_json::Result<serde_json::Value> {
811        serde_json::to_value(self)
812    }
813}
814
815/// Represents an exchange response wrapper from `POST /exchange`.
816#[derive(Debug, Clone, Serialize, Deserialize)]
817#[serde(untagged)]
818pub enum HyperliquidExchangeResponse {
819    /// Successful response with status.
820    Status {
821        /// Status message.
822        status: String,
823        /// Response payload.
824        response: serde_json::Value,
825    },
826    /// Error response.
827    Error {
828        /// Error message.
829        error: String,
830    },
831}
832
833impl HyperliquidExchangeResponse {
834    pub fn is_ok(&self) -> bool {
835        matches!(self, Self::Status { status, .. } if status == RESPONSE_STATUS_OK)
836    }
837}
838
839/// The success status string returned by the Hyperliquid exchange API.
840pub const RESPONSE_STATUS_OK: &str = "ok";
841
842#[cfg(test)]
843mod tests {
844    use rstest::rstest;
845    use rust_decimal_macros::dec;
846    use serde_json::json;
847
848    use super::*;
849
850    #[rstest]
851    fn test_meta_deserialization() {
852        let json = r#"{"universe": [{"name": "BTC", "szDecimals": 5}]}"#;
853
854        let meta: HyperliquidMeta = serde_json::from_str(json).unwrap();
855
856        assert_eq!(meta.universe.len(), 1);
857        assert_eq!(meta.universe[0].name, "BTC");
858        assert_eq!(meta.universe[0].sz_decimals, 5);
859    }
860
861    #[rstest]
862    fn test_funding_history_entry_with_premium() {
863        let json = r#"{
864            "coin": "BTC",
865            "fundingRate": "0.0000125",
866            "premium": "0.00029005",
867            "time": 1769908800000
868        }"#;
869
870        let entry: HyperliquidFundingHistoryEntry = serde_json::from_str(json).unwrap();
871
872        assert_eq!(entry.coin.as_str(), "BTC");
873        assert_eq!(entry.funding_rate, dec!(0.0000125));
874        assert_eq!(entry.premium, Some(dec!(0.00029005)));
875        assert_eq!(entry.time, 1769908800000);
876    }
877
878    #[rstest]
879    fn test_funding_history_entry_without_premium() {
880        // `premium` is optional in the venue response; it must deserialize
881        // to `None` when absent rather than fail.
882        let json = r#"{
883            "coin": "BTC",
884            "fundingRate": "0.0000033",
885            "time": 1769916000000
886        }"#;
887
888        let entry: HyperliquidFundingHistoryEntry = serde_json::from_str(json).unwrap();
889
890        assert!(entry.premium.is_none());
891        assert_eq!(entry.funding_rate, dec!(0.0000033));
892    }
893
894    #[rstest]
895    fn test_recent_trade_deserializes() {
896        // The venue payload carries `hash`/`users` fields the model ignores.
897        let json = r#"{
898            "coin": "BTC",
899            "side": "B",
900            "px": "104250.0",
901            "sz": "0.0123",
902            "hash": "0xabc",
903            "time": 1769916000000,
904            "tid": 987654321,
905            "users": ["0xbuyer", "0xseller"]
906        }"#;
907
908        let trade: HyperliquidRecentTrade = serde_json::from_str(json).unwrap();
909
910        assert_eq!(trade.coin.as_str(), "BTC");
911        assert_eq!(trade.side, HyperliquidSide::Buy);
912        assert_eq!(trade.px, dec!(104250.0));
913        assert_eq!(trade.sz, dec!(0.0123));
914        assert_eq!(trade.time, 1769916000000);
915        assert_eq!(trade.tid, 987654321);
916    }
917
918    #[rstest]
919    fn test_perp_asset_hip3_fields() {
920        let json = r#"{
921            "name": "xyz:TSLA",
922            "szDecimals": 3,
923            "maxLeverage": 10,
924            "onlyIsolated": true,
925            "growthMode": "enabled",
926            "marginMode": "strictIsolated"
927        }"#;
928
929        let asset: PerpAsset = serde_json::from_str(json).unwrap();
930
931        assert_eq!(asset.name, "xyz:TSLA");
932        assert_eq!(asset.sz_decimals, 3);
933        assert_eq!(asset.max_leverage, Some(10));
934        assert_eq!(asset.only_isolated, Some(true));
935        assert_eq!(asset.growth_mode.as_deref(), Some("enabled"));
936        assert_eq!(asset.margin_mode.as_deref(), Some("strictIsolated"));
937    }
938
939    #[rstest]
940    fn test_perp_asset_hip3_fields_absent() {
941        let json = r#"{"name": "BTC", "szDecimals": 5}"#;
942
943        let asset: PerpAsset = serde_json::from_str(json).unwrap();
944
945        assert_eq!(asset.growth_mode, None);
946        assert_eq!(asset.margin_mode, None);
947    }
948
949    #[rstest]
950    fn test_outcome_meta_defaults_missing_side_specs() {
951        let json = r#"{
952            "outcomes": [
953                {
954                    "outcome": 123,
955                    "name": "Recurring",
956                    "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m"
957                }
958            ]
959        }"#;
960
961        let meta: OutcomeMeta = serde_json::from_str(json).unwrap();
962
963        assert_eq!(meta.outcomes.len(), 1);
964        assert_eq!(meta.outcomes[0].outcome, 123);
965        assert!(meta.outcomes[0].side_specs.is_empty());
966    }
967
968    #[rstest]
969    fn test_l2_book_deserialization() {
970        let json = r#"{"coin": "BTC", "levels": [[{"px": "50000", "sz": "1.5"}], [{"px": "50100", "sz": "2.0"}]], "time": 1234567890}"#;
971
972        let book: HyperliquidL2Book = serde_json::from_str(json).unwrap();
973
974        assert_eq!(book.coin, "BTC");
975        assert_eq!(book.levels.len(), 2);
976        assert_eq!(book.time, 1234567890);
977    }
978
979    #[rstest]
980    fn test_exchange_response_deserialization() {
981        let json = r#"{"status": "ok", "response": {"type": "order"}}"#;
982
983        let response: HyperliquidExchangeResponse = serde_json::from_str(json).unwrap();
984        assert!(response.is_ok());
985    }
986
987    #[rstest]
988    fn test_spot_clearinghouse_state_deserialization() {
989        let json = r#"{
990            "balances": [
991                {"coin": "USDC", "token": 0, "total": "14.625485", "hold": "0.0", "entryNtl": "0.0"},
992                {"coin": "PURR", "token": 1, "total": "2000", "hold": "100", "entryNtl": "1234.56"}
993            ]
994        }"#;
995
996        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
997
998        assert_eq!(state.balances.len(), 2);
999        let usdc = &state.balances[0];
1000        assert_eq!(usdc.coin.as_str(), "USDC");
1001        assert_eq!(usdc.token, Some(0));
1002        assert_eq!(usdc.total.to_string(), "14.625485");
1003        assert_eq!(usdc.hold, rust_decimal::Decimal::ZERO);
1004        assert_eq!(usdc.free().to_string(), "14.625485");
1005        assert_eq!(usdc.avg_entry_px(), None);
1006
1007        let purr = &state.balances[1];
1008        assert_eq!(purr.coin.as_str(), "PURR");
1009        assert_eq!(purr.token, Some(1));
1010        assert_eq!(purr.free().to_string(), "1900");
1011        assert_eq!(
1012            purr.avg_entry_px().unwrap(),
1013            rust_decimal_macros::dec!(0.61728)
1014        );
1015    }
1016
1017    #[rstest]
1018    fn test_spot_balance_outcome_side_token_lacks_token_field() {
1019        // HIP-4 outcome side tokens come back without `token` from the venue
1020        let json = r#"{"coin": "+250", "total": "0.0", "hold": "0.0", "entryNtl": "0.0"}"#;
1021        let balance: SpotBalance = serde_json::from_str(json).unwrap();
1022        assert_eq!(balance.coin.as_str(), "+250");
1023        assert_eq!(balance.token, None);
1024    }
1025
1026    #[rstest]
1027    fn test_spot_clearinghouse_state_empty() {
1028        let json = r#"{"balances": []}"#;
1029        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
1030        assert!(state.balances.is_empty());
1031    }
1032
1033    #[rstest]
1034    fn test_spot_balance_handles_missing_entry_ntl() {
1035        let json = r#"{"coin": "HYPE", "token": 150, "total": "5", "hold": "0"}"#;
1036        let balance: SpotBalance = serde_json::from_str(json).unwrap();
1037        assert_eq!(balance.entry_ntl, None);
1038        assert_eq!(balance.avg_entry_px(), None);
1039    }
1040
1041    #[rstest]
1042    fn test_msgpack_serialization_matches_python() {
1043        // Test that msgpack serialization includes the "type" tag properly.
1044        // Python SDK serializes: {"type": "order", "orders": [...], "grouping": "na"}
1045        // We need to verify rmp_serde::to_vec_named produces the same format.
1046
1047        let action = HyperliquidExecAction::Order {
1048            orders: vec![],
1049            grouping: HyperliquidExecGrouping::Na,
1050            builder: None,
1051        };
1052
1053        // First verify JSON is correct
1054        let json = serde_json::to_string(&action).unwrap();
1055        assert!(
1056            json.contains(r#""type":"order""#),
1057            "JSON should have type tag: {json}"
1058        );
1059
1060        // Serialize with msgpack
1061        let msgpack_bytes = rmp_serde::to_vec_named(&action).unwrap();
1062
1063        // Decode back to a generic Value to inspect the structure
1064        let decoded: serde_json::Value = rmp_serde::from_slice(&msgpack_bytes).unwrap();
1065
1066        // The decoded value should have a "type" field
1067        assert!(
1068            decoded.get("type").is_some(),
1069            "MsgPack should have type tag. Decoded: {decoded:?}"
1070        );
1071        assert_eq!(
1072            decoded.get("type").unwrap().as_str().unwrap(),
1073            "order",
1074            "Type should be 'order'"
1075        );
1076        assert!(decoded.get("orders").is_some(), "Should have orders field");
1077        assert!(
1078            decoded.get("grouping").is_some(),
1079            "Should have grouping field"
1080        );
1081    }
1082
1083    #[rstest]
1084    fn test_cancel_action_serializes_fast_flag() {
1085        let action = HyperliquidExecAction::Cancel {
1086            cancels: vec![HyperliquidExecCancelOrderRequest {
1087                asset: 0,
1088                oid: 12345,
1089            }],
1090            fast: Some(true),
1091        };
1092
1093        let value = serde_json::to_value(action).unwrap();
1094
1095        assert_eq!(
1096            value,
1097            json!({
1098                "type": "cancel",
1099                "cancels": [{"a": 0, "o": 12345}],
1100                "f": true,
1101            })
1102        );
1103    }
1104
1105    #[rstest]
1106    fn test_cancel_by_cloid_action_serializes_fast_flag() {
1107        let action = HyperliquidExecAction::CancelByCloid {
1108            cancels: vec![HyperliquidExecCancelByCloidRequest {
1109                asset: 0,
1110                cloid: Cloid::from_hex("0x00000000000000000000000000000000").unwrap(),
1111            }],
1112            fast: Some(true),
1113        };
1114
1115        let value = serde_json::to_value(action).unwrap();
1116
1117        assert_eq!(
1118            value,
1119            json!({
1120                "type": "cancelByCloid",
1121                "cancels": [{
1122                    "asset": 0,
1123                    "cloid": "0x00000000000000000000000000000000",
1124                }],
1125                "f": true,
1126            })
1127        );
1128    }
1129
1130    #[rstest]
1131    fn test_order_response_normal_tpsl_with_waiting_children() {
1132        // `normalTpsl` bracket: the entry rests with an oid, while the SL/TP
1133        // children come back as bare strings until the parent fills or the
1134        // trigger fires.
1135        let json = r#"{
1136            "statuses": [
1137                {"resting": {"oid": 446050656712}},
1138                "waitingForFill",
1139                "waitingForTrigger"
1140            ]
1141        }"#;
1142
1143        let data: HyperliquidExecOrderResponseData = serde_json::from_str(json).unwrap();
1144        assert_eq!(data.statuses.len(), 3);
1145
1146        assert!(matches!(
1147            data.statuses[0],
1148            HyperliquidExecOrderStatus::Resting { ref resting } if resting.oid == 446050656712
1149        ));
1150        assert!(matches!(
1151            data.statuses[1],
1152            HyperliquidExecOrderStatus::Tag(HyperliquidExecOrderStatusTag::WaitingForFill)
1153        ));
1154        assert!(matches!(
1155            data.statuses[2],
1156            HyperliquidExecOrderStatus::Tag(HyperliquidExecOrderStatusTag::WaitingForTrigger)
1157        ));
1158    }
1159
1160    #[rstest]
1161    fn test_user_outcome_split_serialization() {
1162        let action = HyperliquidExecAction::UserOutcome {
1163            op: HyperliquidExecUserOutcomeOp::SplitOutcome(HyperliquidExecSplitOutcomeParams {
1164                outcome: 1,
1165                amount: dec!(123.0),
1166            }),
1167        };
1168
1169        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1170        assert_eq!(
1171            value,
1172            json!({
1173                "type": "userOutcome",
1174                "splitOutcome": { "outcome": 1, "amount": "123.0" }
1175            })
1176        );
1177    }
1178
1179    #[rstest]
1180    fn test_user_outcome_split_msgpack_roundtrip() {
1181        let action = HyperliquidExecAction::UserOutcome {
1182            op: HyperliquidExecUserOutcomeOp::SplitOutcome(HyperliquidExecSplitOutcomeParams {
1183                outcome: 4,
1184                amount: dec!(10),
1185            }),
1186        };
1187
1188        let bytes = rmp_serde::to_vec_named(&action).unwrap();
1189        let decoded: serde_json::Value = rmp_serde::from_slice(&bytes).unwrap();
1190        assert_eq!(
1191            decoded,
1192            json!({
1193                "type": "userOutcome",
1194                "splitOutcome": { "outcome": 4, "amount": "10" }
1195            })
1196        );
1197    }
1198
1199    #[rstest]
1200    fn test_hyperliquid_level_serializes_decimals_as_strings() {
1201        // Decimal fields must serialize back to the string wire form, not a
1202        // JSON number.
1203        let level = HyperliquidLevel {
1204            px: dec!(98450.5),
1205            sz: dec!(2.5),
1206        };
1207        let value = serde_json::to_value(&level).unwrap();
1208        assert_eq!(value, json!({ "px": "98450.5", "sz": "2.5" }));
1209    }
1210
1211    #[rstest]
1212    fn test_user_outcome_merge_outcome_serialization() {
1213        let action = HyperliquidExecAction::UserOutcome {
1214            op: HyperliquidExecUserOutcomeOp::MergeOutcome(HyperliquidExecMergeOutcomeParams {
1215                outcome: 1,
1216                amount: Some(dec!(5.0)),
1217            }),
1218        };
1219        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1220        assert_eq!(
1221            value,
1222            json!({
1223                "type": "userOutcome",
1224                "mergeOutcome": { "outcome": 1, "amount": "5.0" }
1225            })
1226        );
1227    }
1228
1229    #[rstest]
1230    fn test_user_outcome_merge_outcome_null_amount_means_max() {
1231        let action = HyperliquidExecAction::UserOutcome {
1232            op: HyperliquidExecUserOutcomeOp::MergeOutcome(HyperliquidExecMergeOutcomeParams {
1233                outcome: 7,
1234                amount: None,
1235            }),
1236        };
1237        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1238        assert_eq!(
1239            value,
1240            json!({
1241                "type": "userOutcome",
1242                "mergeOutcome": { "outcome": 7, "amount": null }
1243            })
1244        );
1245    }
1246
1247    #[rstest]
1248    fn test_user_outcome_merge_question_serialization() {
1249        let action = HyperliquidExecAction::UserOutcome {
1250            op: HyperliquidExecUserOutcomeOp::MergeQuestion(HyperliquidExecMergeQuestionParams {
1251                question: 9,
1252                amount: Some(dec!(2.0)),
1253            }),
1254        };
1255        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1256        assert_eq!(
1257            value,
1258            json!({
1259                "type": "userOutcome",
1260                "mergeQuestion": { "question": 9, "amount": "2.0" }
1261            })
1262        );
1263    }
1264
1265    #[rstest]
1266    fn test_user_outcome_merge_question_null_amount_means_max() {
1267        let action = HyperliquidExecAction::UserOutcome {
1268            op: HyperliquidExecUserOutcomeOp::MergeQuestion(HyperliquidExecMergeQuestionParams {
1269                question: 9,
1270                amount: None,
1271            }),
1272        };
1273        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1274        assert_eq!(
1275            value,
1276            json!({
1277                "type": "userOutcome",
1278                "mergeQuestion": { "question": 9, "amount": null }
1279            })
1280        );
1281    }
1282
1283    #[rstest]
1284    fn test_user_outcome_negate_outcome_serialization() {
1285        let action = HyperliquidExecAction::UserOutcome {
1286            op: HyperliquidExecUserOutcomeOp::NegateOutcome(HyperliquidExecNegateOutcomeParams {
1287                question: 9,
1288                outcome: 52,
1289                amount: dec!(1.5),
1290            }),
1291        };
1292        let value: serde_json::Value = serde_json::to_value(&action).unwrap();
1293        assert_eq!(
1294            value,
1295            json!({
1296                "type": "userOutcome",
1297                "negateOutcome": { "question": 9, "outcome": 52, "amount": "1.5" }
1298            })
1299        );
1300    }
1301
1302    #[rstest]
1303    fn test_modify_target_serializes_numeric_oid() {
1304        let request = modify_request_with_target(HyperliquidExecModifyTarget::Oid(12345));
1305        let value: serde_json::Value = serde_json::to_value(request).unwrap();
1306
1307        assert_eq!(value["oid"], json!(12345));
1308    }
1309
1310    #[rstest]
1311    fn test_modify_target_serializes_cloid() {
1312        let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
1313        let request = modify_request_with_target(HyperliquidExecModifyTarget::Cloid(cloid));
1314        let value: serde_json::Value = serde_json::to_value(request).unwrap();
1315
1316        assert_eq!(value["oid"], json!("0x1234567890abcdef1234567890abcdef"));
1317    }
1318
1319    fn modify_request_with_target(
1320        oid: HyperliquidExecModifyTarget,
1321    ) -> HyperliquidExecModifyOrderRequest {
1322        HyperliquidExecModifyOrderRequest {
1323            oid,
1324            order: HyperliquidExecPlaceOrderRequest {
1325                asset: 0,
1326                is_buy: true,
1327                price: dec!(51000),
1328                size: dec!(0.2),
1329                reduce_only: false,
1330                kind: HyperliquidExecOrderKind::Limit {
1331                    limit: HyperliquidExecLimitParams {
1332                        tif: HyperliquidExecTif::Gtc,
1333                    },
1334                },
1335                cloid: None,
1336            },
1337        }
1338    }
1339}
1340
1341/// Time-in-force for limit orders in exchange endpoint.
1342///
1343/// These values must match exactly what Hyperliquid expects for proper serialization.
1344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1345pub enum HyperliquidExecTif {
1346    /// Add Liquidity Only (post-only order).
1347    #[serde(rename = "Alo")]
1348    Alo,
1349    /// Immediate or Cancel.
1350    #[serde(rename = "Ioc")]
1351    Ioc,
1352    /// Good Till Canceled.
1353    #[serde(rename = "Gtc")]
1354    Gtc,
1355}
1356
1357/// Take profit or stop loss side for trigger orders in exchange endpoint.
1358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1359pub enum HyperliquidExecTpSl {
1360    /// Take profit.
1361    #[serde(rename = "tp")]
1362    Tp,
1363    /// Stop loss.
1364    #[serde(rename = "sl")]
1365    Sl,
1366}
1367
1368/// Order grouping strategy for linked TP/SL orders in exchange endpoint.
1369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1370pub enum HyperliquidExecGrouping {
1371    /// No grouping semantics.
1372    #[serde(rename = "na")]
1373    #[default]
1374    Na,
1375    /// Normal TP/SL grouping (linked orders).
1376    #[serde(rename = "normalTpsl")]
1377    NormalTpsl,
1378    /// Position-level TP/SL grouping.
1379    #[serde(rename = "positionTpsl")]
1380    PositionTpsl,
1381}
1382
1383/// Order kind specification for the `t` field in exchange endpoint order requests.
1384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1385#[serde(untagged)]
1386pub enum HyperliquidExecOrderKind {
1387    /// Limit order with time-in-force.
1388    Limit {
1389        /// Limit order parameters.
1390        limit: HyperliquidExecLimitParams,
1391    },
1392    /// Trigger order (stop/take profit).
1393    Trigger {
1394        /// Trigger order parameters.
1395        trigger: HyperliquidExecTriggerParams,
1396    },
1397}
1398
1399/// Parameters for limit orders in exchange endpoint.
1400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1401pub struct HyperliquidExecLimitParams {
1402    /// Time-in-force for the limit order.
1403    pub tif: HyperliquidExecTif,
1404}
1405
1406/// Parameters for trigger orders (stop/take profit) in exchange endpoint.
1407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1408#[serde(rename_all = "camelCase")]
1409pub struct HyperliquidExecTriggerParams {
1410    /// Whether to use market price when triggered.
1411    pub is_market: bool,
1412    /// Trigger price as a string.
1413    #[serde(
1414        serialize_with = "serialize_decimal_as_str",
1415        deserialize_with = "deserialize_decimal_from_str"
1416    )]
1417    pub trigger_px: Decimal,
1418    /// Whether this is a take profit or stop loss.
1419    pub tpsl: HyperliquidExecTpSl,
1420}
1421
1422/// Builder code for order attribution in the exchange endpoint.
1423///
1424/// The fee is specified in tenths of a basis point.
1425/// For example, `f: 10` represents 1 basis point (0.01%).
1426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1427pub struct HyperliquidExecBuilderFee {
1428    /// Builder address for attribution.
1429    #[serde(rename = "b")]
1430    pub address: String,
1431    /// Fee in tenths of a basis point.
1432    #[serde(rename = "f")]
1433    pub fee_tenths_bp: u32,
1434}
1435
1436/// Order specification for placing orders via exchange endpoint.
1437///
1438/// This struct represents a single order in the exact format expected
1439/// by the Hyperliquid exchange endpoint.
1440#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1441pub struct HyperliquidExecPlaceOrderRequest {
1442    /// Asset ID.
1443    #[serde(rename = "a")]
1444    pub asset: AssetId,
1445    /// Is buy order (true for buy, false for sell).
1446    #[serde(rename = "b")]
1447    pub is_buy: bool,
1448    /// Price as a string with no trailing zeros.
1449    #[serde(
1450        rename = "p",
1451        serialize_with = "serialize_decimal_as_str",
1452        deserialize_with = "deserialize_decimal_from_str"
1453    )]
1454    pub price: Decimal,
1455    /// Size as a string with no trailing zeros.
1456    #[serde(
1457        rename = "s",
1458        serialize_with = "serialize_decimal_as_str",
1459        deserialize_with = "deserialize_decimal_from_str"
1460    )]
1461    pub size: Decimal,
1462    /// Reduce-only flag.
1463    #[serde(rename = "r")]
1464    pub reduce_only: bool,
1465    /// Order type (limit or trigger).
1466    #[serde(rename = "t")]
1467    pub kind: HyperliquidExecOrderKind,
1468    /// Optional client order ID (128-bit hex).
1469    #[serde(rename = "c", skip_serializing_if = "Option::is_none")]
1470    pub cloid: Option<Cloid>,
1471}
1472
1473/// Cancel specification for canceling orders by order ID via exchange endpoint.
1474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1475pub struct HyperliquidExecCancelOrderRequest {
1476    /// Asset ID.
1477    #[serde(rename = "a")]
1478    pub asset: AssetId,
1479    /// Order ID to cancel.
1480    #[serde(rename = "o")]
1481    pub oid: OrderId,
1482}
1483
1484/// Cancel specification for canceling orders by client order ID via exchange endpoint.
1485///
1486/// Note: Unlike order placement which uses abbreviated field names ("a", "c"),
1487/// cancel-by-cloid uses full field names ("asset", "cloid") per the Hyperliquid API.
1488#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1489pub struct HyperliquidExecCancelByCloidRequest {
1490    /// Asset ID.
1491    pub asset: AssetId,
1492    /// Client order ID to cancel.
1493    pub cloid: Cloid,
1494}
1495
1496/// Target of a modify request.
1497///
1498/// Hyperliquid names this field `oid`, but accepts either a numeric venue
1499/// order ID or a CLOID.
1500#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1501#[serde(untagged)]
1502pub enum HyperliquidExecModifyTarget {
1503    /// Numeric venue order ID.
1504    Oid(OrderId),
1505    /// CLOID.
1506    Cloid(Cloid),
1507}
1508
1509impl HyperliquidExecModifyTarget {
1510    /// Creates a numeric modify target from a Nautilus venue order ID.
1511    ///
1512    /// # Errors
1513    ///
1514    /// Returns an error if the venue order ID is not a numeric Hyperliquid order ID.
1515    pub fn from_venue_order_id(
1516        venue_order_id: &VenueOrderId,
1517    ) -> Result<Self, std::num::ParseIntError> {
1518        venue_order_id.as_str().parse::<OrderId>().map(Self::Oid)
1519    }
1520}
1521
1522impl From<OrderId> for HyperliquidExecModifyTarget {
1523    fn from(value: OrderId) -> Self {
1524        Self::Oid(value)
1525    }
1526}
1527
1528impl From<Cloid> for HyperliquidExecModifyTarget {
1529    fn from(value: Cloid) -> Self {
1530        Self::Cloid(value)
1531    }
1532}
1533
1534/// Modify specification for modifying existing orders via exchange endpoint.
1535///
1536/// The HL API requires the full order spec (same as a place order) plus
1537/// the venue order ID or CLOID to modify.
1538#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1539pub struct HyperliquidExecModifyOrderRequest {
1540    /// Venue order ID or CLOID to modify.
1541    pub oid: HyperliquidExecModifyTarget,
1542    /// Full replacement order specification.
1543    pub order: HyperliquidExecPlaceOrderRequest,
1544}
1545
1546/// Parameters for the HIP-4 `splitOutcome` operation inside a `userOutcome` action.
1547///
1548/// Debits `amount` quote tokens from the user's spot balance and credits both
1549/// the Yes and No side tokens of the referenced outcome.
1550#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1551pub struct HyperliquidExecSplitOutcomeParams {
1552    /// Outcome index (matches `outcomeMeta.outcomes[i].outcome`).
1553    pub outcome: u32,
1554    /// Quote-token amount to split, serialized as a decimal string (e.g. `"123.0"`).
1555    #[serde(
1556        serialize_with = "serialize_decimal_as_str",
1557        deserialize_with = "deserialize_decimal_from_str"
1558    )]
1559    pub amount: Decimal,
1560}
1561
1562/// Parameters for the HIP-4 `mergeOutcome` operation inside a `userOutcome` action.
1563///
1564/// Burns `amount` matched Yes + No side tokens of `outcome` for `amount` quote
1565/// tokens back. `amount = None` serializes as `null`, which the venue treats as
1566/// the maximum mergeable balance.
1567#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1568pub struct HyperliquidExecMergeOutcomeParams {
1569    /// Outcome index whose Yes + No pair is being merged.
1570    pub outcome: u32,
1571    /// Side-token amount to merge, or `None` to merge the maximum available.
1572    #[serde(
1573        default,
1574        serialize_with = "serialize_optional_decimal_as_str",
1575        deserialize_with = "deserialize_optional_decimal_from_str"
1576    )]
1577    pub amount: Option<Decimal>,
1578}
1579
1580/// Parameters for the HIP-4 `mergeQuestion` operation inside a `userOutcome` action.
1581///
1582/// Burns `amount` Yes shares of every outcome associated with `question` for
1583/// `amount` quote tokens back. `amount = None` serializes as `null`, meaning
1584/// the maximum mergeable balance.
1585#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1586pub struct HyperliquidExecMergeQuestionParams {
1587    /// Question identifier whose named outcomes are being merged.
1588    pub question: u32,
1589    /// Yes-share amount to merge per outcome, or `None` for the max.
1590    #[serde(
1591        default,
1592        serialize_with = "serialize_optional_decimal_as_str",
1593        deserialize_with = "deserialize_optional_decimal_from_str"
1594    )]
1595    pub amount: Option<Decimal>,
1596}
1597
1598/// Parameters for the HIP-4 `negateOutcome` operation inside a `userOutcome` action.
1599///
1600/// Converts `amount` `No` shares of `outcome` (within `question`) into `amount`
1601/// `Yes` shares of every other outcome in the same question.
1602#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1603pub struct HyperliquidExecNegateOutcomeParams {
1604    /// Question identifier the outcome belongs to.
1605    pub question: u32,
1606    /// Outcome index whose `No` shares are being negated.
1607    pub outcome: u32,
1608    /// Side-token amount to negate, serialized as a decimal string.
1609    #[serde(
1610        serialize_with = "serialize_decimal_as_str",
1611        deserialize_with = "deserialize_decimal_from_str"
1612    )]
1613    pub amount: Decimal,
1614}
1615
1616/// Operations carried by the [`HyperliquidExecAction::UserOutcome`] action.
1617///
1618/// Each variant serializes as a single-keyed object (for example,
1619/// `{ "splitOutcome": { ... } }`) and is flattened into the outer action
1620/// envelope alongside `"type": "userOutcome"` to match the Hyperliquid wire
1621/// format.
1622#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1623pub enum HyperliquidExecUserOutcomeOp {
1624    /// Split `amount` quote tokens into `amount` Yes plus `amount` No shares.
1625    #[serde(rename = "splitOutcome")]
1626    SplitOutcome(HyperliquidExecSplitOutcomeParams),
1627    /// Merge `amount` Yes + No side-token pairs of `outcome` back into quote
1628    /// tokens (reverse of [`Self::SplitOutcome`]).
1629    #[serde(rename = "mergeOutcome")]
1630    MergeOutcome(HyperliquidExecMergeOutcomeParams),
1631    /// Merge `amount` Yes shares of every outcome in `question` into quote
1632    /// tokens (multi-outcome reverse of `splitOutcome`).
1633    #[serde(rename = "mergeQuestion")]
1634    MergeQuestion(HyperliquidExecMergeQuestionParams),
1635    /// Swap `amount` `No` shares of one outcome into `Yes` shares of every
1636    /// other outcome in the same question.
1637    #[serde(rename = "negateOutcome")]
1638    NegateOutcome(HyperliquidExecNegateOutcomeParams),
1639}
1640
1641/// TWAP (Time-Weighted Average Price) order specification for exchange endpoint.
1642#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1643pub struct HyperliquidExecTwapRequest {
1644    /// Asset ID.
1645    #[serde(rename = "a")]
1646    pub asset: AssetId,
1647    /// Is buy order.
1648    #[serde(rename = "b")]
1649    pub is_buy: bool,
1650    /// Total size to execute.
1651    #[serde(
1652        rename = "s",
1653        serialize_with = "serialize_decimal_as_str",
1654        deserialize_with = "deserialize_decimal_from_str"
1655    )]
1656    pub size: Decimal,
1657    /// Duration in milliseconds.
1658    #[serde(rename = "m")]
1659    pub duration_ms: u64,
1660}
1661
1662/// All possible exchange actions for the Hyperliquid `/exchange` endpoint.
1663///
1664/// Each variant corresponds to a specific action type that can be performed
1665/// through the exchange API. The serialization uses the exact action type
1666/// names expected by Hyperliquid.
1667#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1668#[serde(tag = "type")]
1669pub enum HyperliquidExecAction {
1670    /// Place one or more orders.
1671    #[serde(rename = "order")]
1672    Order {
1673        /// List of orders to place.
1674        orders: Vec<HyperliquidExecPlaceOrderRequest>,
1675        /// Grouping strategy for TP/SL orders.
1676        #[serde(default)]
1677        grouping: HyperliquidExecGrouping,
1678        /// Optional builder code for attribution.
1679        #[serde(skip_serializing_if = "Option::is_none")]
1680        builder: Option<HyperliquidExecBuilderFee>,
1681    },
1682
1683    /// Cancel orders by order ID.
1684    #[serde(rename = "cancel")]
1685    Cancel {
1686        /// Orders to cancel.
1687        cancels: Vec<HyperliquidExecCancelOrderRequest>,
1688        /// Optional fast-cancel flag.
1689        #[serde(rename = "f", skip_serializing_if = "Option::is_none")]
1690        fast: Option<bool>,
1691    },
1692
1693    /// Cancel orders by client order ID.
1694    #[serde(rename = "cancelByCloid")]
1695    CancelByCloid {
1696        /// Orders to cancel by CLOID.
1697        cancels: Vec<HyperliquidExecCancelByCloidRequest>,
1698        /// Optional fast-cancel flag.
1699        #[serde(rename = "f", skip_serializing_if = "Option::is_none")]
1700        fast: Option<bool>,
1701    },
1702
1703    /// Modify a single order.
1704    #[serde(rename = "modify")]
1705    Modify {
1706        /// Order modification specification.
1707        #[serde(flatten)]
1708        modify: HyperliquidExecModifyOrderRequest,
1709    },
1710
1711    /// Modify multiple orders atomically.
1712    #[serde(rename = "batchModify")]
1713    BatchModify {
1714        /// Multiple order modifications.
1715        modifies: Vec<HyperliquidExecModifyOrderRequest>,
1716    },
1717
1718    /// Schedule automatic order cancellation (dead man's switch).
1719    #[serde(rename = "scheduleCancel")]
1720    ScheduleCancel {
1721        /// Time in milliseconds when orders should be cancelled.
1722        /// If None, clears the existing schedule.
1723        #[serde(skip_serializing_if = "Option::is_none")]
1724        time: Option<u64>,
1725    },
1726
1727    /// Update leverage for a position.
1728    #[serde(rename = "updateLeverage")]
1729    UpdateLeverage {
1730        /// Asset ID.
1731        #[serde(rename = "a")]
1732        asset: AssetId,
1733        /// Whether to use cross margin.
1734        #[serde(rename = "isCross")]
1735        is_cross: bool,
1736        /// Leverage value.
1737        #[serde(rename = "leverage")]
1738        leverage: u32,
1739    },
1740
1741    /// Update isolated margin for a position.
1742    #[serde(rename = "updateIsolatedMargin")]
1743    UpdateIsolatedMargin {
1744        /// Asset ID.
1745        #[serde(rename = "a")]
1746        asset: AssetId,
1747        /// Margin delta as a string.
1748        #[serde(
1749            rename = "delta",
1750            serialize_with = "serialize_decimal_as_str",
1751            deserialize_with = "deserialize_decimal_from_str"
1752        )]
1753        delta: Decimal,
1754    },
1755
1756    /// Transfer USD between spot and perp accounts.
1757    #[serde(rename = "usdClassTransfer")]
1758    UsdClassTransfer {
1759        /// Source account type.
1760        from: String,
1761        /// Destination account type.
1762        to: String,
1763        /// Amount to transfer.
1764        #[serde(
1765            serialize_with = "serialize_decimal_as_str",
1766            deserialize_with = "deserialize_decimal_from_str"
1767        )]
1768        amount: Decimal,
1769    },
1770
1771    /// HIP-4 outcome-side token management (`splitOutcome` and related ops).
1772    ///
1773    /// The active op is carried via [`HyperliquidExecUserOutcomeOp`] and
1774    /// flattened into this action envelope, producing wire payloads such as
1775    /// `{ "type": "userOutcome", "splitOutcome": { ... } }`.
1776    #[serde(rename = "userOutcome")]
1777    UserOutcome {
1778        /// Operation to perform on the user's outcome balances.
1779        #[serde(flatten)]
1780        op: HyperliquidExecUserOutcomeOp,
1781    },
1782
1783    /// Place a TWAP order.
1784    #[serde(rename = "twapPlace")]
1785    TwapPlace {
1786        /// TWAP order specification.
1787        #[serde(flatten)]
1788        twap: HyperliquidExecTwapRequest,
1789    },
1790
1791    /// Cancel a TWAP order.
1792    #[serde(rename = "twapCancel")]
1793    TwapCancel {
1794        /// Asset ID.
1795        #[serde(rename = "a")]
1796        asset: AssetId,
1797        /// TWAP ID.
1798        #[serde(rename = "t")]
1799        twap_id: u64,
1800    },
1801
1802    /// No-operation to invalidate pending nonces.
1803    #[serde(rename = "noop")]
1804    Noop,
1805}
1806
1807/// Exchange request envelope for the `/exchange` endpoint.
1808///
1809/// This is the top-level structure sent to Hyperliquid's exchange endpoint.
1810/// It includes the action to perform along with authentication and metadata.
1811#[derive(Debug, Clone, Serialize)]
1812#[serde(rename_all = "camelCase")]
1813pub struct HyperliquidExecRequest {
1814    /// The exchange action to perform.
1815    pub action: HyperliquidExecAction,
1816    /// Request nonce for replay protection (milliseconds timestamp recommended).
1817    pub nonce: u64,
1818    /// ECC signature over the action and nonce.
1819    pub signature: String,
1820    /// Optional vault address for sub-account trading.
1821    #[serde(skip_serializing_if = "Option::is_none")]
1822    pub vault_address: Option<String>,
1823    /// Optional expiration time in milliseconds.
1824    /// Note: Using this field increases rate limit weight by 5x if the request expires.
1825    #[serde(skip_serializing_if = "Option::is_none")]
1826    pub expires_after: Option<u64>,
1827}
1828
1829/// Exchange response envelope from the `/exchange` endpoint.
1830#[derive(Debug, Clone, Serialize, Deserialize)]
1831pub struct HyperliquidExecResponse {
1832    /// Response status ("ok" for success).
1833    pub status: String,
1834    /// Response payload.
1835    pub response: HyperliquidExecResponseData,
1836}
1837
1838/// Response data containing the actual response payload from exchange endpoint.
1839#[derive(Debug, Clone, Serialize, Deserialize)]
1840#[serde(tag = "type")]
1841pub enum HyperliquidExecResponseData {
1842    /// Response for order actions.
1843    #[serde(rename = "order")]
1844    Order {
1845        /// Order response data.
1846        data: HyperliquidExecOrderResponseData,
1847    },
1848    /// Response for cancel actions.
1849    #[serde(rename = "cancel")]
1850    Cancel {
1851        /// Cancel response data.
1852        data: HyperliquidExecCancelResponseData,
1853    },
1854    /// Response for modify actions.
1855    #[serde(rename = "modify")]
1856    Modify {
1857        /// Modify response data.
1858        data: HyperliquidExecModifyResponseData,
1859    },
1860    /// Generic response for other actions.
1861    #[serde(rename = "default")]
1862    Default,
1863    /// Catch-all for unknown response types.
1864    #[serde(other)]
1865    Unknown,
1866}
1867
1868/// Order response data containing status for each order from exchange endpoint.
1869#[derive(Debug, Clone, Serialize, Deserialize)]
1870pub struct HyperliquidExecOrderResponseData {
1871    /// Status for each order in the request.
1872    pub statuses: Vec<HyperliquidExecOrderStatus>,
1873}
1874
1875/// Cancel response data containing status for each cancellation from exchange endpoint.
1876#[derive(Debug, Clone, Serialize, Deserialize)]
1877pub struct HyperliquidExecCancelResponseData {
1878    /// Status for each cancellation in the request.
1879    pub statuses: Vec<HyperliquidExecCancelStatus>,
1880}
1881
1882/// Modify response data containing status for each modification from exchange endpoint.
1883#[derive(Debug, Clone, Serialize, Deserialize)]
1884pub struct HyperliquidExecModifyResponseData {
1885    /// Status for each modification in the request.
1886    pub statuses: Vec<HyperliquidExecModifyStatus>,
1887}
1888
1889/// Status of an individual order submission via exchange endpoint.
1890#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1891#[serde(untagged)]
1892pub enum HyperliquidExecOrderStatus {
1893    /// Order is resting on the order book.
1894    Resting {
1895        /// Resting order information.
1896        resting: HyperliquidExecRestingInfo,
1897    },
1898    /// Order was filled immediately.
1899    Filled {
1900        /// Fill information.
1901        filled: HyperliquidExecFilledInfo,
1902    },
1903    /// Order submission failed.
1904    Error {
1905        /// Error message.
1906        error: String,
1907    },
1908    /// Bare status string for a trigger child of a `normalTpsl` group (SL/TP),
1909    /// which Hyperliquid serializes as a JSON string rather than an object
1910    /// (for example `"waitingForFill"` or `"waitingForTrigger"`).
1911    Tag(HyperliquidExecOrderStatusTag),
1912}
1913
1914/// Status tags Hyperliquid serializes as a bare JSON string.
1915///
1916/// Trigger children of a `normalTpsl` group, plus standalone trigger orders
1917/// that have not armed yet, fall in this bucket: the venue defers order-id
1918/// assignment until activation.
1919#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1920pub enum HyperliquidExecOrderStatusTag {
1921    /// Trigger child parked until the parent (entry) order fills.
1922    #[serde(rename = "waitingForFill")]
1923    WaitingForFill,
1924    /// Trigger child parked until its trigger price condition is met.
1925    #[serde(rename = "waitingForTrigger")]
1926    WaitingForTrigger,
1927}
1928
1929/// Information about a resting order via exchange endpoint.
1930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1931pub struct HyperliquidExecRestingInfo {
1932    /// Order ID assigned by Hyperliquid.
1933    pub oid: OrderId,
1934}
1935
1936/// Information about a filled order via exchange endpoint.
1937#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1938pub struct HyperliquidExecFilledInfo {
1939    /// Total filled size.
1940    #[serde(
1941        rename = "totalSz",
1942        serialize_with = "serialize_decimal_as_str",
1943        deserialize_with = "deserialize_decimal_from_str"
1944    )]
1945    pub total_sz: Decimal,
1946    /// Average fill price.
1947    #[serde(
1948        rename = "avgPx",
1949        serialize_with = "serialize_decimal_as_str",
1950        deserialize_with = "deserialize_decimal_from_str"
1951    )]
1952    pub avg_px: Decimal,
1953    /// Order ID.
1954    pub oid: OrderId,
1955}
1956
1957/// Status of an individual order cancellation via exchange endpoint.
1958#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1959#[serde(untagged)]
1960pub enum HyperliquidExecCancelStatus {
1961    /// Cancellation succeeded.
1962    Success(String), // Usually "success"
1963    /// Cancellation failed.
1964    Error {
1965        /// Error message.
1966        error: String,
1967    },
1968}
1969
1970/// Status of an individual order modification via exchange endpoint.
1971#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1972#[serde(untagged)]
1973pub enum HyperliquidExecModifyStatus {
1974    /// Modification succeeded.
1975    Success(String), // Usually "success"
1976    /// Modification failed.
1977    Error {
1978        /// Error message.
1979        error: String,
1980    },
1981}
1982
1983/// Complete clearinghouse state response from `POST /info` with `{ "type": "clearinghouseState", "user": "address" }`.
1984/// This provides account positions, margin information, and balances.
1985#[derive(Debug, Clone, Serialize, Deserialize)]
1986#[serde(rename_all = "camelCase")]
1987pub struct ClearinghouseState {
1988    /// List of asset positions (perpetual contracts).
1989    #[serde(default)]
1990    pub asset_positions: Vec<AssetPosition>,
1991    /// Cross margin summary information.
1992    #[serde(default)]
1993    pub cross_margin_summary: Option<CrossMarginSummary>,
1994    /// Withdrawable balance (top-level field).
1995    #[serde(
1996        default,
1997        serialize_with = "serialize_optional_decimal_as_str",
1998        deserialize_with = "deserialize_optional_decimal_from_str"
1999    )]
2000    pub withdrawable: Option<Decimal>,
2001    /// Time of the state snapshot (milliseconds since epoch).
2002    #[serde(default)]
2003    pub time: Option<u64>,
2004}
2005
2006/// A single asset position in the clearinghouse state.
2007#[derive(Debug, Clone, Serialize, Deserialize)]
2008#[serde(rename_all = "camelCase")]
2009pub struct AssetPosition {
2010    /// Position information.
2011    pub position: PositionData,
2012    /// Type of position.
2013    #[serde(rename = "type")]
2014    pub position_type: HyperliquidPositionType,
2015}
2016
2017/// Leverage information for a position.
2018#[derive(Debug, Clone, Serialize, Deserialize)]
2019#[serde(rename_all = "camelCase")]
2020pub struct LeverageInfo {
2021    #[serde(rename = "type")]
2022    pub leverage_type: HyperliquidLeverageType,
2023    /// Leverage value.
2024    pub value: u32,
2025}
2026
2027/// Cumulative funding breakdown for a position.
2028#[derive(Debug, Clone, Serialize, Deserialize)]
2029#[serde(rename_all = "camelCase")]
2030pub struct CumFundingInfo {
2031    /// All-time cumulative funding.
2032    #[serde(
2033        rename = "allTime",
2034        serialize_with = "serialize_decimal_as_str",
2035        deserialize_with = "deserialize_decimal_from_str"
2036    )]
2037    pub all_time: Decimal,
2038    /// Funding since position opened.
2039    #[serde(
2040        rename = "sinceOpen",
2041        serialize_with = "serialize_decimal_as_str",
2042        deserialize_with = "deserialize_decimal_from_str"
2043    )]
2044    pub since_open: Decimal,
2045    /// Funding since last position change.
2046    #[serde(
2047        rename = "sinceChange",
2048        serialize_with = "serialize_decimal_as_str",
2049        deserialize_with = "deserialize_decimal_from_str"
2050    )]
2051    pub since_change: Decimal,
2052}
2053
2054/// Detailed position data for an asset.
2055#[derive(Debug, Clone, Serialize, Deserialize)]
2056#[serde(rename_all = "camelCase")]
2057pub struct PositionData {
2058    /// Asset symbol/coin (e.g., "BTC").
2059    pub coin: Ustr,
2060    /// Cumulative funding breakdown.
2061    #[serde(rename = "cumFunding")]
2062    pub cum_funding: CumFundingInfo,
2063    /// Entry price for the position.
2064    #[serde(
2065        rename = "entryPx",
2066        serialize_with = "serialize_optional_decimal_as_str",
2067        deserialize_with = "deserialize_optional_decimal_from_str",
2068        default
2069    )]
2070    pub entry_px: Option<Decimal>,
2071    /// Leverage information for the position.
2072    pub leverage: LeverageInfo,
2073    /// Liquidation price.
2074    #[serde(
2075        rename = "liquidationPx",
2076        serialize_with = "serialize_optional_decimal_as_str",
2077        deserialize_with = "deserialize_optional_decimal_from_str",
2078        default
2079    )]
2080    pub liquidation_px: Option<Decimal>,
2081    /// Margin used for this position.
2082    #[serde(
2083        rename = "marginUsed",
2084        serialize_with = "serialize_decimal_as_str",
2085        deserialize_with = "deserialize_decimal_from_str"
2086    )]
2087    pub margin_used: Decimal,
2088    /// Maximum leverage allowed for this asset.
2089    #[serde(rename = "maxLeverage", default)]
2090    pub max_leverage: Option<u32>,
2091    /// Position value.
2092    #[serde(
2093        rename = "positionValue",
2094        serialize_with = "serialize_decimal_as_str",
2095        deserialize_with = "deserialize_decimal_from_str"
2096    )]
2097    pub position_value: Decimal,
2098    /// Return on equity percentage.
2099    #[serde(
2100        rename = "returnOnEquity",
2101        serialize_with = "serialize_decimal_as_str",
2102        deserialize_with = "deserialize_decimal_from_str"
2103    )]
2104    pub return_on_equity: Decimal,
2105    /// Position size (positive for long, negative for short).
2106    #[serde(
2107        rename = "szi",
2108        serialize_with = "serialize_decimal_as_str",
2109        deserialize_with = "deserialize_decimal_from_str"
2110    )]
2111    pub szi: Decimal,
2112    /// Unrealized PnL.
2113    #[serde(
2114        rename = "unrealizedPnl",
2115        serialize_with = "serialize_decimal_as_str",
2116        deserialize_with = "deserialize_decimal_from_str"
2117    )]
2118    pub unrealized_pnl: Decimal,
2119}
2120
2121/// Complete spot clearinghouse state response from `POST /info`
2122/// with `{ "type": "spotClearinghouseState", "user": "address" }`.
2123///
2124/// Provides per-token spot balances for the queried address. Under unified or
2125/// portfolio margin accounts this is the source of truth for spot holdings.
2126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2127#[serde(rename_all = "camelCase")]
2128pub struct SpotClearinghouseState {
2129    /// Per-token spot balances.
2130    #[serde(default)]
2131    pub balances: Vec<SpotBalance>,
2132}
2133
2134/// A single token balance entry from `spotClearinghouseState.balances`.
2135#[derive(Debug, Clone, Serialize, Deserialize)]
2136#[serde(rename_all = "camelCase")]
2137pub struct SpotBalance {
2138    /// Token name (e.g., "USDC", "PURR").
2139    pub coin: Ustr,
2140    /// Token index matching `spotMeta.tokens[*].index`. Omitted by the venue
2141    /// for HIP-4 outcome side tokens (`+E` coins).
2142    #[serde(default)]
2143    pub token: Option<u32>,
2144    /// Total token balance (on-hold plus available).
2145    #[serde(
2146        serialize_with = "serialize_decimal_as_str",
2147        deserialize_with = "deserialize_decimal_from_str"
2148    )]
2149    pub total: Decimal,
2150    /// Portion currently reserved for resting orders.
2151    #[serde(
2152        serialize_with = "serialize_decimal_as_str",
2153        deserialize_with = "deserialize_decimal_from_str"
2154    )]
2155    pub hold: Decimal,
2156    /// Entry notional value (position cost basis in USDC).
2157    #[serde(
2158        default,
2159        serialize_with = "serialize_optional_decimal_as_str",
2160        deserialize_with = "deserialize_optional_decimal_from_str"
2161    )]
2162    pub entry_ntl: Option<Decimal>,
2163}
2164
2165impl SpotBalance {
2166    /// Returns the balance freely available to trade or withdraw (`total - hold`).
2167    #[must_use]
2168    pub fn free(&self) -> Decimal {
2169        (self.total - self.hold).max(Decimal::ZERO)
2170    }
2171
2172    /// Returns the average entry price derived from `entry_ntl / total`, if both are non-zero.
2173    #[must_use]
2174    pub fn avg_entry_px(&self) -> Option<Decimal> {
2175        let entry_ntl = self.entry_ntl?;
2176
2177        if entry_ntl.is_zero() || self.total.is_zero() {
2178            return None;
2179        }
2180
2181        Some(entry_ntl / self.total)
2182    }
2183}
2184
2185/// Cross margin summary information.
2186#[derive(Debug, Clone, Serialize, Deserialize)]
2187#[serde(rename_all = "camelCase")]
2188pub struct CrossMarginSummary {
2189    /// Account value in USD.
2190    #[serde(
2191        rename = "accountValue",
2192        serialize_with = "serialize_decimal_as_str",
2193        deserialize_with = "deserialize_decimal_from_str"
2194    )]
2195    pub account_value: Decimal,
2196    /// Total notional position value.
2197    #[serde(
2198        rename = "totalNtlPos",
2199        serialize_with = "serialize_decimal_as_str",
2200        deserialize_with = "deserialize_decimal_from_str"
2201    )]
2202    pub total_ntl_pos: Decimal,
2203    /// Total raw USD value (collateral).
2204    #[serde(
2205        rename = "totalRawUsd",
2206        serialize_with = "serialize_decimal_as_str",
2207        deserialize_with = "deserialize_decimal_from_str"
2208    )]
2209    pub total_raw_usd: Decimal,
2210    /// Total margin used across all positions.
2211    #[serde(
2212        rename = "totalMarginUsed",
2213        serialize_with = "serialize_decimal_as_str",
2214        deserialize_with = "deserialize_decimal_from_str"
2215    )]
2216    pub total_margin_used: Decimal,
2217    /// Withdrawable balance.
2218    #[serde(
2219        rename = "withdrawable",
2220        default,
2221        serialize_with = "serialize_optional_decimal_as_str",
2222        deserialize_with = "deserialize_optional_decimal_from_str"
2223    )]
2224    pub withdrawable: Option<Decimal>,
2225}