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/// Optional perpetuals metadata with asset contexts from `{ "type": "metaAndAssetCtxs" }`.
258/// Returns a tuple: `[PerpMeta, Vec<PerpAssetCtx>]`
259#[derive(Debug, Clone, Serialize, Deserialize)]
260#[serde(untagged)]
261pub enum PerpMetaAndCtxs {
262    /// Tuple format: [meta, contexts]
263    Payload(Box<(PerpMeta, Vec<PerpAssetCtx>)>),
264}
265
266/// Runtime context for a perpetual asset (mark prices, funding, etc).
267#[derive(Debug, Clone, Serialize, Deserialize)]
268#[serde(rename_all = "camelCase")]
269pub struct PerpAssetCtx {
270    /// Mark price as string.
271    #[serde(default)]
272    pub mark_px: Option<String>,
273    /// Mid price as string.
274    #[serde(default)]
275    pub mid_px: Option<String>,
276    /// Funding rate as string.
277    #[serde(default)]
278    pub funding: Option<String>,
279    /// Open interest as string.
280    #[serde(default)]
281    pub open_interest: Option<String>,
282}
283
284/// Optional spot metadata with asset contexts from `{ "type": "spotMetaAndAssetCtxs" }`.
285/// Returns a tuple: `[SpotMeta, Vec<SpotAssetCtx>]`
286#[derive(Debug, Clone, Serialize, Deserialize)]
287#[serde(untagged)]
288pub enum SpotMetaAndCtxs {
289    /// Tuple format: [meta, contexts]
290    Payload(Box<(SpotMeta, Vec<SpotAssetCtx>)>),
291}
292
293/// Runtime context for a spot pair (prices, volumes, etc).
294#[derive(Debug, Clone, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase")]
296pub struct SpotAssetCtx {
297    /// Mark price as string.
298    #[serde(default)]
299    pub mark_px: Option<String>,
300    /// Mid price as string.
301    #[serde(default)]
302    pub mid_px: Option<String>,
303    /// 24h volume as string.
304    #[serde(default)]
305    pub day_volume: Option<String>,
306}
307
308/// Represents an L2 order book snapshot from `POST /info`.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct HyperliquidL2Book {
311    /// Coin symbol.
312    pub coin: Ustr,
313    /// Order book levels: [bids, asks].
314    pub levels: Vec<Vec<HyperliquidLevel>>,
315    /// Timestamp in milliseconds.
316    pub time: u64,
317}
318
319/// Represents an order book level with price and size.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct HyperliquidLevel {
322    /// Price level.
323    pub px: String,
324    /// Size at this level.
325    pub sz: String,
326}
327
328/// Represents user fills response from `POST /info`.
329///
330/// The Hyperliquid API returns fills directly as an array, not wrapped in an object.
331pub type HyperliquidFills = Vec<HyperliquidFill>;
332
333/// Represents metadata about available markets from `POST /info`.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct HyperliquidMeta {
336    #[serde(default)]
337    pub universe: Vec<HyperliquidAssetInfo>,
338}
339
340/// Represents a single candle (OHLCV bar) from Hyperliquid.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(rename_all = "camelCase")]
343pub struct HyperliquidCandle {
344    /// Candle start timestamp in milliseconds.
345    #[serde(rename = "t")]
346    pub timestamp: u64,
347    /// Candle end timestamp in milliseconds.
348    #[serde(rename = "T")]
349    pub end_timestamp: u64,
350    /// Open price.
351    #[serde(rename = "o")]
352    pub open: String,
353    /// High price.
354    #[serde(rename = "h")]
355    pub high: String,
356    /// Low price.
357    #[serde(rename = "l")]
358    pub low: String,
359    /// Close price.
360    #[serde(rename = "c")]
361    pub close: String,
362    /// Volume.
363    #[serde(rename = "v")]
364    pub volume: String,
365    /// Number of trades (optional).
366    #[serde(rename = "n", default)]
367    pub num_trades: Option<u64>,
368}
369
370/// Represents a single funding history entry from the `fundingHistory` info endpoint.
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct HyperliquidFundingHistoryEntry {
373    /// Coin symbol (raw Hyperliquid name, e.g. `"BTC"`).
374    pub coin: Ustr,
375    /// Funding rate applied at the interval end, as a decimal string.
376    #[serde(rename = "fundingRate")]
377    pub funding_rate: String,
378    /// Premium at the time of funding, as a decimal string.
379    #[serde(default)]
380    pub premium: Option<String>,
381    /// Timestamp in milliseconds marking the end of the funding interval.
382    pub time: u64,
383}
384
385/// Represents an individual fill from user fills.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct HyperliquidFill {
388    /// Coin symbol.
389    pub coin: Ustr,
390    /// Fill price.
391    pub px: String,
392    /// Fill size.
393    pub sz: String,
394    /// Order side (buy/sell).
395    pub side: HyperliquidSide,
396    /// Fill timestamp in milliseconds.
397    pub time: u64,
398    /// Position size before this fill.
399    #[serde(rename = "startPosition")]
400    pub start_position: String,
401    /// Fill direction (open/close).
402    pub dir: HyperliquidFillDirection,
403    /// Closed P&L from this fill.
404    #[serde(rename = "closedPnl")]
405    pub closed_pnl: String,
406    /// Hash reference.
407    pub hash: String,
408    /// Order ID that generated this fill.
409    pub oid: u64,
410    /// Crossed status.
411    pub crossed: bool,
412    /// Fee paid for this fill.
413    pub fee: String,
414    /// Token the fee was paid in (e.g. "USDC", "HYPE").
415    #[serde(rename = "feeToken")]
416    pub fee_token: Ustr,
417}
418
419/// Represents order status response from `POST /info` with `type: "orderStatus"`.
420///
421/// The API returns `{"status": "order", "order": {...}}` when the order is known,
422/// or `{"status": "unknownOid"}` when the oid is not found.
423#[derive(Debug, Clone, Serialize, Deserialize)]
424#[serde(tag = "status", rename_all = "camelCase")]
425pub enum HyperliquidOrderStatus {
426    Order { order: HyperliquidOrderStatusEntry },
427    UnknownOid,
428}
429
430impl HyperliquidOrderStatus {
431    /// Consumes the response and returns the inner entry if the order was found.
432    #[must_use]
433    pub fn into_order(self) -> Option<HyperliquidOrderStatusEntry> {
434        match self {
435            Self::Order { order } => Some(order),
436            Self::UnknownOid => None,
437        }
438    }
439}
440
441/// Represents an individual order status entry.
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct HyperliquidOrderStatusEntry {
444    /// Order information.
445    pub order: HyperliquidOrderInfo,
446    /// Current status.
447    pub status: HyperliquidOrderStatusEnum,
448    /// Status timestamp in milliseconds.
449    #[serde(rename = "statusTimestamp")]
450    pub status_timestamp: u64,
451}
452
453/// Represents order information within an order status entry.
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct HyperliquidOrderInfo {
456    /// Coin symbol.
457    pub coin: Ustr,
458    /// Order side (buy/sell).
459    pub side: HyperliquidSide,
460    /// Limit price.
461    #[serde(rename = "limitPx")]
462    pub limit_px: String,
463    /// Order size.
464    pub sz: String,
465    /// Order ID.
466    pub oid: u64,
467    /// Order timestamp in milliseconds.
468    pub timestamp: u64,
469    /// Original order size.
470    #[serde(rename = "origSz")]
471    pub orig_sz: String,
472    /// Optional client order ID (hex representation of the keccak256 cloid).
473    #[serde(default)]
474    pub cloid: Option<String>,
475}
476
477/// ECC signature components for Hyperliquid exchange requests.
478#[derive(Debug, Clone, Serialize)]
479pub struct HyperliquidSignature {
480    /// R component of the signature.
481    pub r: String,
482    /// S component of the signature.
483    pub s: String,
484    /// V component (recovery ID) of the signature.
485    pub v: u64,
486}
487
488impl HyperliquidSignature {
489    /// Creates a new [`HyperliquidSignature`] from pre-formatted components.
490    #[must_use]
491    pub fn new(r: String, s: String, v: u64) -> Self {
492        Self { r, s, v }
493    }
494
495    /// Formats as Ethereum hex signature: `0x` + r(64) + s(64) + v(2).
496    #[must_use]
497    pub fn to_hex(&self) -> String {
498        let r = self.r.strip_prefix("0x").unwrap_or(&self.r);
499        let s = self.s.strip_prefix("0x").unwrap_or(&self.s);
500        format!("0x{r}{s}{:02x}", self.v)
501    }
502
503    /// Parses a hex signature string (0x + 64 hex r + 64 hex s + 2 hex v) into components.
504    pub fn from_hex(sig_hex: &str) -> Result<Self, String> {
505        let sig_hex = sig_hex.strip_prefix("0x").unwrap_or(sig_hex);
506
507        if sig_hex.len() != 130 {
508            return Err(format!(
509                "Invalid signature length: expected 130 hex chars, was {}",
510                sig_hex.len()
511            ));
512        }
513
514        let r = format!("0x{}", &sig_hex[0..64]);
515        let s = format!("0x{}", &sig_hex[64..128]);
516        let v = u64::from_str_radix(&sig_hex[128..130], 16)
517            .map_err(|e| format!("Failed to parse v component: {e}"))?;
518
519        Ok(Self { r, s, v })
520    }
521}
522
523/// Represents an exchange action request wrapper for `POST /exchange`.
524#[derive(Debug, Clone, Serialize)]
525pub struct HyperliquidExchangeRequest<T> {
526    /// The action to perform.
527    #[serde(rename = "action")]
528    pub action: T,
529    /// Request nonce for replay protection.
530    #[serde(rename = "nonce")]
531    pub nonce: u64,
532    /// ECC signature over the action.
533    #[serde(rename = "signature")]
534    pub signature: HyperliquidSignature,
535    /// Optional vault address for sub-account trading.
536    #[serde(rename = "vaultAddress", skip_serializing_if = "Option::is_none")]
537    pub vault_address: Option<String>,
538    /// Optional expiration time in milliseconds.
539    #[serde(rename = "expiresAfter", skip_serializing_if = "Option::is_none")]
540    pub expires_after: Option<u64>,
541}
542
543impl<T> HyperliquidExchangeRequest<T>
544where
545    T: Serialize,
546{
547    /// Creates a new exchange request with the given action.
548    #[must_use]
549    pub fn new(action: T, nonce: u64, signature: HyperliquidSignature) -> Self {
550        Self {
551            action,
552            nonce,
553            signature,
554            vault_address: None,
555            expires_after: None,
556        }
557    }
558
559    /// Creates a new exchange request with vault address for sub-account trading.
560    #[must_use]
561    pub fn with_vault(
562        action: T,
563        nonce: u64,
564        signature: HyperliquidSignature,
565        vault_address: String,
566    ) -> Self {
567        Self {
568            action,
569            nonce,
570            signature,
571            vault_address: Some(vault_address),
572            expires_after: None,
573        }
574    }
575
576    /// Convert to JSON value for signing purposes.
577    pub fn to_sign_value(&self) -> serde_json::Result<serde_json::Value> {
578        serde_json::to_value(self)
579    }
580}
581
582/// Represents an exchange response wrapper from `POST /exchange`.
583#[derive(Debug, Clone, Serialize, Deserialize)]
584#[serde(untagged)]
585pub enum HyperliquidExchangeResponse {
586    /// Successful response with status.
587    Status {
588        /// Status message.
589        status: String,
590        /// Response payload.
591        response: serde_json::Value,
592    },
593    /// Error response.
594    Error {
595        /// Error message.
596        error: String,
597    },
598}
599
600impl HyperliquidExchangeResponse {
601    pub fn is_ok(&self) -> bool {
602        matches!(self, Self::Status { status, .. } if status == RESPONSE_STATUS_OK)
603    }
604}
605
606/// The success status string returned by the Hyperliquid exchange API.
607pub const RESPONSE_STATUS_OK: &str = "ok";
608
609#[cfg(test)]
610mod tests {
611    use rstest::rstest;
612
613    use super::*;
614
615    #[rstest]
616    fn test_meta_deserialization() {
617        let json = r#"{"universe": [{"name": "BTC", "szDecimals": 5}]}"#;
618
619        let meta: HyperliquidMeta = serde_json::from_str(json).unwrap();
620
621        assert_eq!(meta.universe.len(), 1);
622        assert_eq!(meta.universe[0].name, "BTC");
623        assert_eq!(meta.universe[0].sz_decimals, 5);
624    }
625
626    #[rstest]
627    fn test_funding_history_entry_with_premium() {
628        let json = r#"{
629            "coin": "BTC",
630            "fundingRate": "0.0000125",
631            "premium": "0.00029005",
632            "time": 1769908800000
633        }"#;
634
635        let entry: HyperliquidFundingHistoryEntry = serde_json::from_str(json).unwrap();
636
637        assert_eq!(entry.coin.as_str(), "BTC");
638        assert_eq!(entry.funding_rate, "0.0000125");
639        assert_eq!(entry.premium.as_deref(), Some("0.00029005"));
640        assert_eq!(entry.time, 1769908800000);
641    }
642
643    #[rstest]
644    fn test_funding_history_entry_without_premium() {
645        // `premium` is optional in the venue response; it must deserialize
646        // to `None` when absent rather than fail.
647        let json = r#"{
648            "coin": "BTC",
649            "fundingRate": "0.0000033",
650            "time": 1769916000000
651        }"#;
652
653        let entry: HyperliquidFundingHistoryEntry = serde_json::from_str(json).unwrap();
654
655        assert!(entry.premium.is_none());
656        assert_eq!(entry.funding_rate, "0.0000033");
657    }
658
659    #[rstest]
660    fn test_perp_asset_hip3_fields() {
661        let json = r#"{
662            "name": "xyz:TSLA",
663            "szDecimals": 3,
664            "maxLeverage": 10,
665            "onlyIsolated": true,
666            "growthMode": "enabled",
667            "marginMode": "strictIsolated"
668        }"#;
669
670        let asset: PerpAsset = serde_json::from_str(json).unwrap();
671
672        assert_eq!(asset.name, "xyz:TSLA");
673        assert_eq!(asset.sz_decimals, 3);
674        assert_eq!(asset.max_leverage, Some(10));
675        assert_eq!(asset.only_isolated, Some(true));
676        assert_eq!(asset.growth_mode.as_deref(), Some("enabled"));
677        assert_eq!(asset.margin_mode.as_deref(), Some("strictIsolated"));
678    }
679
680    #[rstest]
681    fn test_perp_asset_hip3_fields_absent() {
682        let json = r#"{"name": "BTC", "szDecimals": 5}"#;
683
684        let asset: PerpAsset = serde_json::from_str(json).unwrap();
685
686        assert_eq!(asset.growth_mode, None);
687        assert_eq!(asset.margin_mode, None);
688    }
689
690    #[rstest]
691    fn test_l2_book_deserialization() {
692        let json = r#"{"coin": "BTC", "levels": [[{"px": "50000", "sz": "1.5"}], [{"px": "50100", "sz": "2.0"}]], "time": 1234567890}"#;
693
694        let book: HyperliquidL2Book = serde_json::from_str(json).unwrap();
695
696        assert_eq!(book.coin, "BTC");
697        assert_eq!(book.levels.len(), 2);
698        assert_eq!(book.time, 1234567890);
699    }
700
701    #[rstest]
702    fn test_exchange_response_deserialization() {
703        let json = r#"{"status": "ok", "response": {"type": "order"}}"#;
704
705        let response: HyperliquidExchangeResponse = serde_json::from_str(json).unwrap();
706        assert!(response.is_ok());
707    }
708
709    #[rstest]
710    fn test_spot_clearinghouse_state_deserialization() {
711        let json = r#"{
712            "balances": [
713                {"coin": "USDC", "token": 0, "total": "14.625485", "hold": "0.0", "entryNtl": "0.0"},
714                {"coin": "PURR", "token": 1, "total": "2000", "hold": "100", "entryNtl": "1234.56"}
715            ]
716        }"#;
717
718        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
719
720        assert_eq!(state.balances.len(), 2);
721        let usdc = &state.balances[0];
722        assert_eq!(usdc.coin.as_str(), "USDC");
723        assert_eq!(usdc.token, 0);
724        assert_eq!(usdc.total.to_string(), "14.625485");
725        assert_eq!(usdc.hold, rust_decimal::Decimal::ZERO);
726        assert_eq!(usdc.free().to_string(), "14.625485");
727        assert_eq!(usdc.avg_entry_px(), None);
728
729        let purr = &state.balances[1];
730        assert_eq!(purr.coin.as_str(), "PURR");
731        assert_eq!(purr.token, 1);
732        assert_eq!(purr.free().to_string(), "1900");
733        assert_eq!(
734            purr.avg_entry_px().unwrap(),
735            rust_decimal_macros::dec!(0.61728)
736        );
737    }
738
739    #[rstest]
740    fn test_spot_clearinghouse_state_empty() {
741        let json = r#"{"balances": []}"#;
742        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
743        assert!(state.balances.is_empty());
744    }
745
746    #[rstest]
747    fn test_spot_balance_handles_missing_entry_ntl() {
748        let json = r#"{"coin": "HYPE", "token": 150, "total": "5", "hold": "0"}"#;
749        let balance: SpotBalance = serde_json::from_str(json).unwrap();
750        assert_eq!(balance.entry_ntl, None);
751        assert_eq!(balance.avg_entry_px(), None);
752    }
753
754    #[rstest]
755    fn test_msgpack_serialization_matches_python() {
756        // Test that msgpack serialization includes the "type" tag properly.
757        // Python SDK serializes: {"type": "order", "orders": [...], "grouping": "na"}
758        // We need to verify rmp_serde::to_vec_named produces the same format.
759
760        let action = HyperliquidExecAction::Order {
761            orders: vec![],
762            grouping: HyperliquidExecGrouping::Na,
763            builder: None,
764        };
765
766        // First verify JSON is correct
767        let json = serde_json::to_string(&action).unwrap();
768        assert!(
769            json.contains(r#""type":"order""#),
770            "JSON should have type tag: {json}"
771        );
772
773        // Serialize with msgpack
774        let msgpack_bytes = rmp_serde::to_vec_named(&action).unwrap();
775
776        // Decode back to a generic Value to inspect the structure
777        let decoded: serde_json::Value = rmp_serde::from_slice(&msgpack_bytes).unwrap();
778
779        // The decoded value should have a "type" field
780        assert!(
781            decoded.get("type").is_some(),
782            "MsgPack should have type tag. Decoded: {decoded:?}"
783        );
784        assert_eq!(
785            decoded.get("type").unwrap().as_str().unwrap(),
786            "order",
787            "Type should be 'order'"
788        );
789        assert!(decoded.get("orders").is_some(), "Should have orders field");
790        assert!(
791            decoded.get("grouping").is_some(),
792            "Should have grouping field"
793        );
794    }
795}
796
797/// Time-in-force for limit orders in exchange endpoint.
798///
799/// These values must match exactly what Hyperliquid expects for proper serialization.
800#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
801pub enum HyperliquidExecTif {
802    /// Add Liquidity Only (post-only order).
803    #[serde(rename = "Alo")]
804    Alo,
805    /// Immediate or Cancel.
806    #[serde(rename = "Ioc")]
807    Ioc,
808    /// Good Till Canceled.
809    #[serde(rename = "Gtc")]
810    Gtc,
811}
812
813/// Take profit or stop loss side for trigger orders in exchange endpoint.
814#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
815pub enum HyperliquidExecTpSl {
816    /// Take profit.
817    #[serde(rename = "tp")]
818    Tp,
819    /// Stop loss.
820    #[serde(rename = "sl")]
821    Sl,
822}
823
824/// Order grouping strategy for linked TP/SL orders in exchange endpoint.
825#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
826pub enum HyperliquidExecGrouping {
827    /// No grouping semantics.
828    #[serde(rename = "na")]
829    #[default]
830    Na,
831    /// Normal TP/SL grouping (linked orders).
832    #[serde(rename = "normalTpsl")]
833    NormalTpsl,
834    /// Position-level TP/SL grouping.
835    #[serde(rename = "positionTpsl")]
836    PositionTpsl,
837}
838
839/// Order kind specification for the `t` field in exchange endpoint order requests.
840#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
841#[serde(untagged)]
842pub enum HyperliquidExecOrderKind {
843    /// Limit order with time-in-force.
844    Limit {
845        /// Limit order parameters.
846        limit: HyperliquidExecLimitParams,
847    },
848    /// Trigger order (stop/take profit).
849    Trigger {
850        /// Trigger order parameters.
851        trigger: HyperliquidExecTriggerParams,
852    },
853}
854
855/// Parameters for limit orders in exchange endpoint.
856#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
857pub struct HyperliquidExecLimitParams {
858    /// Time-in-force for the limit order.
859    pub tif: HyperliquidExecTif,
860}
861
862/// Parameters for trigger orders (stop/take profit) in exchange endpoint.
863#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
864#[serde(rename_all = "camelCase")]
865pub struct HyperliquidExecTriggerParams {
866    /// Whether to use market price when triggered.
867    pub is_market: bool,
868    /// Trigger price as a string.
869    #[serde(
870        serialize_with = "crate::common::parse::serialize_decimal_as_str",
871        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
872    )]
873    pub trigger_px: Decimal,
874    /// Whether this is a take profit or stop loss.
875    pub tpsl: HyperliquidExecTpSl,
876}
877
878/// Builder code for order attribution in the exchange endpoint.
879///
880/// The fee is specified in tenths of a basis point.
881/// For example, `f: 10` represents 1 basis point (0.01%).
882#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
883pub struct HyperliquidExecBuilderFee {
884    /// Builder address for attribution.
885    #[serde(rename = "b")]
886    pub address: String,
887    /// Fee in tenths of a basis point.
888    #[serde(rename = "f")]
889    pub fee_tenths_bp: u32,
890}
891
892/// Order specification for placing orders via exchange endpoint.
893///
894/// This struct represents a single order in the exact format expected
895/// by the Hyperliquid exchange endpoint.
896#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
897pub struct HyperliquidExecPlaceOrderRequest {
898    /// Asset ID.
899    #[serde(rename = "a")]
900    pub asset: AssetId,
901    /// Is buy order (true for buy, false for sell).
902    #[serde(rename = "b")]
903    pub is_buy: bool,
904    /// Price as a string with no trailing zeros.
905    #[serde(
906        rename = "p",
907        serialize_with = "crate::common::parse::serialize_decimal_as_str",
908        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
909    )]
910    pub price: Decimal,
911    /// Size as a string with no trailing zeros.
912    #[serde(
913        rename = "s",
914        serialize_with = "crate::common::parse::serialize_decimal_as_str",
915        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
916    )]
917    pub size: Decimal,
918    /// Reduce-only flag.
919    #[serde(rename = "r")]
920    pub reduce_only: bool,
921    /// Order type (limit or trigger).
922    #[serde(rename = "t")]
923    pub kind: HyperliquidExecOrderKind,
924    /// Optional client order ID (128-bit hex).
925    #[serde(rename = "c", skip_serializing_if = "Option::is_none")]
926    pub cloid: Option<Cloid>,
927}
928
929/// Cancel specification for canceling orders by order ID via exchange endpoint.
930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
931pub struct HyperliquidExecCancelOrderRequest {
932    /// Asset ID.
933    #[serde(rename = "a")]
934    pub asset: AssetId,
935    /// Order ID to cancel.
936    #[serde(rename = "o")]
937    pub oid: OrderId,
938}
939
940/// Cancel specification for canceling orders by client order ID via exchange endpoint.
941///
942/// Note: Unlike order placement which uses abbreviated field names ("a", "c"),
943/// cancel-by-cloid uses full field names ("asset", "cloid") per the Hyperliquid API.
944#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
945pub struct HyperliquidExecCancelByCloidRequest {
946    /// Asset ID.
947    pub asset: AssetId,
948    /// Client order ID to cancel.
949    pub cloid: Cloid,
950}
951
952/// Modify specification for modifying existing orders via exchange endpoint.
953///
954/// The HL API requires the full order spec (same as a place order) plus
955/// the venue order ID to modify.
956#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
957pub struct HyperliquidExecModifyOrderRequest {
958    /// Venue order ID to modify.
959    pub oid: OrderId,
960    /// Full replacement order specification.
961    pub order: HyperliquidExecPlaceOrderRequest,
962}
963
964/// TWAP (Time-Weighted Average Price) order specification for exchange endpoint.
965#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
966pub struct HyperliquidExecTwapRequest {
967    /// Asset ID.
968    #[serde(rename = "a")]
969    pub asset: AssetId,
970    /// Is buy order.
971    #[serde(rename = "b")]
972    pub is_buy: bool,
973    /// Total size to execute.
974    #[serde(
975        rename = "s",
976        serialize_with = "crate::common::parse::serialize_decimal_as_str",
977        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
978    )]
979    pub size: Decimal,
980    /// Duration in milliseconds.
981    #[serde(rename = "m")]
982    pub duration_ms: u64,
983}
984
985/// All possible exchange actions for the Hyperliquid `/exchange` endpoint.
986///
987/// Each variant corresponds to a specific action type that can be performed
988/// through the exchange API. The serialization uses the exact action type
989/// names expected by Hyperliquid.
990#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
991#[serde(tag = "type")]
992pub enum HyperliquidExecAction {
993    /// Place one or more orders.
994    #[serde(rename = "order")]
995    Order {
996        /// List of orders to place.
997        orders: Vec<HyperliquidExecPlaceOrderRequest>,
998        /// Grouping strategy for TP/SL orders.
999        #[serde(default)]
1000        grouping: HyperliquidExecGrouping,
1001        /// Optional builder code for attribution.
1002        #[serde(skip_serializing_if = "Option::is_none")]
1003        builder: Option<HyperliquidExecBuilderFee>,
1004    },
1005
1006    /// Cancel orders by order ID.
1007    #[serde(rename = "cancel")]
1008    Cancel {
1009        /// Orders to cancel.
1010        cancels: Vec<HyperliquidExecCancelOrderRequest>,
1011    },
1012
1013    /// Cancel orders by client order ID.
1014    #[serde(rename = "cancelByCloid")]
1015    CancelByCloid {
1016        /// Orders to cancel by CLOID.
1017        cancels: Vec<HyperliquidExecCancelByCloidRequest>,
1018    },
1019
1020    /// Modify a single order.
1021    #[serde(rename = "modify")]
1022    Modify {
1023        /// Order modification specification.
1024        #[serde(flatten)]
1025        modify: HyperliquidExecModifyOrderRequest,
1026    },
1027
1028    /// Modify multiple orders atomically.
1029    #[serde(rename = "batchModify")]
1030    BatchModify {
1031        /// Multiple order modifications.
1032        modifies: Vec<HyperliquidExecModifyOrderRequest>,
1033    },
1034
1035    /// Schedule automatic order cancellation (dead man's switch).
1036    #[serde(rename = "scheduleCancel")]
1037    ScheduleCancel {
1038        /// Time in milliseconds when orders should be cancelled.
1039        /// If None, clears the existing schedule.
1040        #[serde(skip_serializing_if = "Option::is_none")]
1041        time: Option<u64>,
1042    },
1043
1044    /// Update leverage for a position.
1045    #[serde(rename = "updateLeverage")]
1046    UpdateLeverage {
1047        /// Asset ID.
1048        #[serde(rename = "a")]
1049        asset: AssetId,
1050        /// Whether to use cross margin.
1051        #[serde(rename = "isCross")]
1052        is_cross: bool,
1053        /// Leverage value.
1054        #[serde(rename = "leverage")]
1055        leverage: u32,
1056    },
1057
1058    /// Update isolated margin for a position.
1059    #[serde(rename = "updateIsolatedMargin")]
1060    UpdateIsolatedMargin {
1061        /// Asset ID.
1062        #[serde(rename = "a")]
1063        asset: AssetId,
1064        /// Margin delta as a string.
1065        #[serde(
1066            rename = "delta",
1067            serialize_with = "crate::common::parse::serialize_decimal_as_str",
1068            deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1069        )]
1070        delta: Decimal,
1071    },
1072
1073    /// Transfer USD between spot and perp accounts.
1074    #[serde(rename = "usdClassTransfer")]
1075    UsdClassTransfer {
1076        /// Source account type.
1077        from: String,
1078        /// Destination account type.
1079        to: String,
1080        /// Amount to transfer.
1081        #[serde(
1082            serialize_with = "crate::common::parse::serialize_decimal_as_str",
1083            deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1084        )]
1085        amount: Decimal,
1086    },
1087
1088    /// Place a TWAP order.
1089    #[serde(rename = "twapPlace")]
1090    TwapPlace {
1091        /// TWAP order specification.
1092        #[serde(flatten)]
1093        twap: HyperliquidExecTwapRequest,
1094    },
1095
1096    /// Cancel a TWAP order.
1097    #[serde(rename = "twapCancel")]
1098    TwapCancel {
1099        /// Asset ID.
1100        #[serde(rename = "a")]
1101        asset: AssetId,
1102        /// TWAP ID.
1103        #[serde(rename = "t")]
1104        twap_id: u64,
1105    },
1106
1107    /// No-operation to invalidate pending nonces.
1108    #[serde(rename = "noop")]
1109    Noop,
1110}
1111
1112/// Exchange request envelope for the `/exchange` endpoint.
1113///
1114/// This is the top-level structure sent to Hyperliquid's exchange endpoint.
1115/// It includes the action to perform along with authentication and metadata.
1116#[derive(Debug, Clone, Serialize)]
1117#[serde(rename_all = "camelCase")]
1118pub struct HyperliquidExecRequest {
1119    /// The exchange action to perform.
1120    pub action: HyperliquidExecAction,
1121    /// Request nonce for replay protection (milliseconds timestamp recommended).
1122    pub nonce: u64,
1123    /// ECC signature over the action and nonce.
1124    pub signature: String,
1125    /// Optional vault address for sub-account trading.
1126    #[serde(skip_serializing_if = "Option::is_none")]
1127    pub vault_address: Option<String>,
1128    /// Optional expiration time in milliseconds.
1129    /// Note: Using this field increases rate limit weight by 5x if the request expires.
1130    #[serde(skip_serializing_if = "Option::is_none")]
1131    pub expires_after: Option<u64>,
1132}
1133
1134/// Exchange response envelope from the `/exchange` endpoint.
1135#[derive(Debug, Clone, Serialize, Deserialize)]
1136pub struct HyperliquidExecResponse {
1137    /// Response status ("ok" for success).
1138    pub status: String,
1139    /// Response payload.
1140    pub response: HyperliquidExecResponseData,
1141}
1142
1143/// Response data containing the actual response payload from exchange endpoint.
1144#[derive(Debug, Clone, Serialize, Deserialize)]
1145#[serde(tag = "type")]
1146pub enum HyperliquidExecResponseData {
1147    /// Response for order actions.
1148    #[serde(rename = "order")]
1149    Order {
1150        /// Order response data.
1151        data: HyperliquidExecOrderResponseData,
1152    },
1153    /// Response for cancel actions.
1154    #[serde(rename = "cancel")]
1155    Cancel {
1156        /// Cancel response data.
1157        data: HyperliquidExecCancelResponseData,
1158    },
1159    /// Response for modify actions.
1160    #[serde(rename = "modify")]
1161    Modify {
1162        /// Modify response data.
1163        data: HyperliquidExecModifyResponseData,
1164    },
1165    /// Generic response for other actions.
1166    #[serde(rename = "default")]
1167    Default,
1168    /// Catch-all for unknown response types.
1169    #[serde(other)]
1170    Unknown,
1171}
1172
1173/// Order response data containing status for each order from exchange endpoint.
1174#[derive(Debug, Clone, Serialize, Deserialize)]
1175pub struct HyperliquidExecOrderResponseData {
1176    /// Status for each order in the request.
1177    pub statuses: Vec<HyperliquidExecOrderStatus>,
1178}
1179
1180/// Cancel response data containing status for each cancellation from exchange endpoint.
1181#[derive(Debug, Clone, Serialize, Deserialize)]
1182pub struct HyperliquidExecCancelResponseData {
1183    /// Status for each cancellation in the request.
1184    pub statuses: Vec<HyperliquidExecCancelStatus>,
1185}
1186
1187/// Modify response data containing status for each modification from exchange endpoint.
1188#[derive(Debug, Clone, Serialize, Deserialize)]
1189pub struct HyperliquidExecModifyResponseData {
1190    /// Status for each modification in the request.
1191    pub statuses: Vec<HyperliquidExecModifyStatus>,
1192}
1193
1194/// Status of an individual order submission via exchange endpoint.
1195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1196#[serde(untagged)]
1197pub enum HyperliquidExecOrderStatus {
1198    /// Order is resting on the order book.
1199    Resting {
1200        /// Resting order information.
1201        resting: HyperliquidExecRestingInfo,
1202    },
1203    /// Order was filled immediately.
1204    Filled {
1205        /// Fill information.
1206        filled: HyperliquidExecFilledInfo,
1207    },
1208    /// Order submission failed.
1209    Error {
1210        /// Error message.
1211        error: String,
1212    },
1213}
1214
1215/// Information about a resting order via exchange endpoint.
1216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1217pub struct HyperliquidExecRestingInfo {
1218    /// Order ID assigned by Hyperliquid.
1219    pub oid: OrderId,
1220}
1221
1222/// Information about a filled order via exchange endpoint.
1223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1224pub struct HyperliquidExecFilledInfo {
1225    /// Total filled size.
1226    #[serde(
1227        rename = "totalSz",
1228        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1229        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1230    )]
1231    pub total_sz: Decimal,
1232    /// Average fill price.
1233    #[serde(
1234        rename = "avgPx",
1235        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1236        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1237    )]
1238    pub avg_px: Decimal,
1239    /// Order ID.
1240    pub oid: OrderId,
1241}
1242
1243/// Status of an individual order cancellation via exchange endpoint.
1244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1245#[serde(untagged)]
1246pub enum HyperliquidExecCancelStatus {
1247    /// Cancellation succeeded.
1248    Success(String), // Usually "success"
1249    /// Cancellation failed.
1250    Error {
1251        /// Error message.
1252        error: String,
1253    },
1254}
1255
1256/// Status of an individual order modification via exchange endpoint.
1257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1258#[serde(untagged)]
1259pub enum HyperliquidExecModifyStatus {
1260    /// Modification succeeded.
1261    Success(String), // Usually "success"
1262    /// Modification failed.
1263    Error {
1264        /// Error message.
1265        error: String,
1266    },
1267}
1268
1269/// Complete clearinghouse state response from `POST /info` with `{ "type": "clearinghouseState", "user": "address" }`.
1270/// This provides account positions, margin information, and balances.
1271#[derive(Debug, Clone, Serialize, Deserialize)]
1272#[serde(rename_all = "camelCase")]
1273pub struct ClearinghouseState {
1274    /// List of asset positions (perpetual contracts).
1275    #[serde(default)]
1276    pub asset_positions: Vec<AssetPosition>,
1277    /// Cross margin summary information.
1278    #[serde(default)]
1279    pub cross_margin_summary: Option<CrossMarginSummary>,
1280    /// Withdrawable balance (top-level field).
1281    #[serde(
1282        default,
1283        serialize_with = "crate::common::parse::serialize_optional_decimal_as_str",
1284        deserialize_with = "crate::common::parse::deserialize_optional_decimal_from_str"
1285    )]
1286    pub withdrawable: Option<Decimal>,
1287    /// Time of the state snapshot (milliseconds since epoch).
1288    #[serde(default)]
1289    pub time: Option<u64>,
1290}
1291
1292/// A single asset position in the clearinghouse state.
1293#[derive(Debug, Clone, Serialize, Deserialize)]
1294#[serde(rename_all = "camelCase")]
1295pub struct AssetPosition {
1296    /// Position information.
1297    pub position: PositionData,
1298    /// Type of position.
1299    #[serde(rename = "type")]
1300    pub position_type: HyperliquidPositionType,
1301}
1302
1303/// Leverage information for a position.
1304#[derive(Debug, Clone, Serialize, Deserialize)]
1305#[serde(rename_all = "camelCase")]
1306pub struct LeverageInfo {
1307    #[serde(rename = "type")]
1308    pub leverage_type: HyperliquidLeverageType,
1309    /// Leverage value.
1310    pub value: u32,
1311}
1312
1313/// Cumulative funding breakdown for a position.
1314#[derive(Debug, Clone, Serialize, Deserialize)]
1315#[serde(rename_all = "camelCase")]
1316pub struct CumFundingInfo {
1317    /// All-time cumulative funding.
1318    #[serde(
1319        rename = "allTime",
1320        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1321        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1322    )]
1323    pub all_time: Decimal,
1324    /// Funding since position opened.
1325    #[serde(
1326        rename = "sinceOpen",
1327        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1328        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1329    )]
1330    pub since_open: Decimal,
1331    /// Funding since last position change.
1332    #[serde(
1333        rename = "sinceChange",
1334        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1335        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1336    )]
1337    pub since_change: Decimal,
1338}
1339
1340/// Detailed position data for an asset.
1341#[derive(Debug, Clone, Serialize, Deserialize)]
1342#[serde(rename_all = "camelCase")]
1343pub struct PositionData {
1344    /// Asset symbol/coin (e.g., "BTC").
1345    pub coin: Ustr,
1346    /// Cumulative funding breakdown.
1347    #[serde(rename = "cumFunding")]
1348    pub cum_funding: CumFundingInfo,
1349    /// Entry price for the position.
1350    #[serde(
1351        rename = "entryPx",
1352        serialize_with = "crate::common::parse::serialize_optional_decimal_as_str",
1353        deserialize_with = "crate::common::parse::deserialize_optional_decimal_from_str",
1354        default
1355    )]
1356    pub entry_px: Option<Decimal>,
1357    /// Leverage information for the position.
1358    pub leverage: LeverageInfo,
1359    /// Liquidation price.
1360    #[serde(
1361        rename = "liquidationPx",
1362        serialize_with = "crate::common::parse::serialize_optional_decimal_as_str",
1363        deserialize_with = "crate::common::parse::deserialize_optional_decimal_from_str",
1364        default
1365    )]
1366    pub liquidation_px: Option<Decimal>,
1367    /// Margin used for this position.
1368    #[serde(
1369        rename = "marginUsed",
1370        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1371        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1372    )]
1373    pub margin_used: Decimal,
1374    /// Maximum leverage allowed for this asset.
1375    #[serde(rename = "maxLeverage", default)]
1376    pub max_leverage: Option<u32>,
1377    /// Position value.
1378    #[serde(
1379        rename = "positionValue",
1380        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1381        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1382    )]
1383    pub position_value: Decimal,
1384    /// Return on equity percentage.
1385    #[serde(
1386        rename = "returnOnEquity",
1387        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1388        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1389    )]
1390    pub return_on_equity: Decimal,
1391    /// Position size (positive for long, negative for short).
1392    #[serde(
1393        rename = "szi",
1394        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1395        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1396    )]
1397    pub szi: Decimal,
1398    /// Unrealized PnL.
1399    #[serde(
1400        rename = "unrealizedPnl",
1401        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1402        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1403    )]
1404    pub unrealized_pnl: Decimal,
1405}
1406
1407/// Complete spot clearinghouse state response from `POST /info`
1408/// with `{ "type": "spotClearinghouseState", "user": "address" }`.
1409///
1410/// Provides per-token spot balances for the queried address. Under unified or
1411/// portfolio margin accounts this is the source of truth for spot holdings.
1412#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1413#[serde(rename_all = "camelCase")]
1414pub struct SpotClearinghouseState {
1415    /// Per-token spot balances.
1416    #[serde(default)]
1417    pub balances: Vec<SpotBalance>,
1418}
1419
1420/// A single token balance entry from `spotClearinghouseState.balances`.
1421#[derive(Debug, Clone, Serialize, Deserialize)]
1422#[serde(rename_all = "camelCase")]
1423pub struct SpotBalance {
1424    /// Token name (e.g., "USDC", "PURR").
1425    pub coin: Ustr,
1426    /// Token index matching `spotMeta.tokens[*].index`.
1427    pub token: u32,
1428    /// Total token balance (on-hold plus available).
1429    #[serde(
1430        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1431        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1432    )]
1433    pub total: Decimal,
1434    /// Portion currently reserved for resting orders.
1435    #[serde(
1436        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1437        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1438    )]
1439    pub hold: Decimal,
1440    /// Entry notional value (position cost basis in USDC).
1441    #[serde(
1442        default,
1443        serialize_with = "crate::common::parse::serialize_optional_decimal_as_str",
1444        deserialize_with = "crate::common::parse::deserialize_optional_decimal_from_str"
1445    )]
1446    pub entry_ntl: Option<Decimal>,
1447}
1448
1449impl SpotBalance {
1450    /// Returns the balance freely available to trade or withdraw (`total - hold`).
1451    #[must_use]
1452    pub fn free(&self) -> Decimal {
1453        (self.total - self.hold).max(Decimal::ZERO)
1454    }
1455
1456    /// Returns the average entry price derived from `entry_ntl / total`, if both are non-zero.
1457    #[must_use]
1458    pub fn avg_entry_px(&self) -> Option<Decimal> {
1459        let entry_ntl = self.entry_ntl?;
1460
1461        if entry_ntl.is_zero() || self.total.is_zero() {
1462            return None;
1463        }
1464
1465        Some(entry_ntl / self.total)
1466    }
1467}
1468
1469/// Cross margin summary information.
1470#[derive(Debug, Clone, Serialize, Deserialize)]
1471#[serde(rename_all = "camelCase")]
1472pub struct CrossMarginSummary {
1473    /// Account value in USD.
1474    #[serde(
1475        rename = "accountValue",
1476        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1477        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1478    )]
1479    pub account_value: Decimal,
1480    /// Total notional position value.
1481    #[serde(
1482        rename = "totalNtlPos",
1483        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1484        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1485    )]
1486    pub total_ntl_pos: Decimal,
1487    /// Total raw USD value (collateral).
1488    #[serde(
1489        rename = "totalRawUsd",
1490        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1491        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1492    )]
1493    pub total_raw_usd: Decimal,
1494    /// Total margin used across all positions.
1495    #[serde(
1496        rename = "totalMarginUsed",
1497        serialize_with = "crate::common::parse::serialize_decimal_as_str",
1498        deserialize_with = "crate::common::parse::deserialize_decimal_from_str"
1499    )]
1500    pub total_margin_used: Decimal,
1501    /// Withdrawable balance.
1502    #[serde(
1503        rename = "withdrawable",
1504        default,
1505        serialize_with = "crate::common::parse::serialize_optional_decimal_as_str",
1506        deserialize_with = "crate::common::parse::deserialize_optional_decimal_from_str"
1507    )]
1508    pub withdrawable: Option<Decimal>,
1509}