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