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