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