Skip to main content

hl_types/
account.rs

1use std::collections::HashMap;
2
3use rust_decimal::Decimal;
4use serde::{Deserialize, Serialize};
5
6/// A position held on Hyperliquid.
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "camelCase")]
9#[non_exhaustive]
10pub struct HlPosition {
11    /// The coin/asset symbol.
12    pub coin: String,
13    /// Position size (negative for short).
14    pub size: Decimal,
15    /// Average entry price.
16    pub entry_px: Decimal,
17    /// Unrealised PnL.
18    pub unrealized_pnl: Decimal,
19    /// Leverage used.
20    pub leverage: Decimal,
21    /// Liquidation price, if applicable.
22    pub liquidation_px: Option<Decimal>,
23}
24
25impl HlPosition {
26    /// Creates a new `HlPosition`.
27    pub fn new(
28        coin: String,
29        size: Decimal,
30        entry_px: Decimal,
31        unrealized_pnl: Decimal,
32        leverage: Decimal,
33        liquidation_px: Option<Decimal>,
34    ) -> Self {
35        Self {
36            coin,
37            size,
38            entry_px,
39            unrealized_pnl,
40            leverage,
41            liquidation_px,
42        }
43    }
44}
45
46/// A trade fill on Hyperliquid.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49#[non_exhaustive]
50pub struct HlFill {
51    /// The coin/asset symbol.
52    pub coin: String,
53    /// Fill price.
54    pub px: Decimal,
55    /// Fill size.
56    pub sz: Decimal,
57    /// Whether the fill was on the buy side.
58    pub is_buy: bool,
59    /// Timestamp in milliseconds.
60    pub timestamp: u64,
61    /// Fee paid.
62    pub fee: Decimal,
63    /// Realized PnL from closing a position (0.0 if this fill opened a position).
64    pub closed_pnl: Decimal,
65}
66
67impl HlFill {
68    /// Creates a new `HlFill`.
69    pub fn new(
70        coin: String,
71        px: Decimal,
72        sz: Decimal,
73        is_buy: bool,
74        timestamp: u64,
75        fee: Decimal,
76        closed_pnl: Decimal,
77    ) -> Self {
78        Self {
79            coin,
80            px,
81            sz,
82            is_buy,
83            timestamp,
84            fee,
85            closed_pnl,
86        }
87    }
88}
89
90/// Snapshot of an account's state.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93#[non_exhaustive]
94pub struct HlAccountState {
95    /// Account equity.
96    pub equity: Decimal,
97    /// Available margin.
98    pub margin_available: Decimal,
99    /// Open positions.
100    pub positions: Vec<HlPosition>,
101}
102
103impl HlAccountState {
104    /// Creates a new `HlAccountState`.
105    pub fn new(equity: Decimal, margin_available: Decimal, positions: Vec<HlPosition>) -> Self {
106        Self {
107            equity,
108            margin_available,
109            positions,
110        }
111    }
112}
113
114/// Summary of a vault the user participates in.
115///
116/// Returned by the `vaultSummaries` info endpoint. Fields that the API may
117/// add in the future are captured in `extra`.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct HlVaultSummary {
121    /// On-chain vault address.
122    pub vault_address: String,
123    /// Human-readable vault name.
124    pub name: String,
125    /// Vault leader's equity (USDC).
126    #[serde(default)]
127    pub leader_equity: Option<Decimal>,
128    /// Total follower equity (USDC).
129    #[serde(default)]
130    pub follower_equity: Option<Decimal>,
131    /// Vault's all-time PnL.
132    #[serde(default)]
133    pub all_time_pnl: Option<Decimal>,
134    /// Any additional fields returned by the API.
135    #[serde(flatten)]
136    pub extra: HashMap<String, serde_json::Value>,
137}
138
139/// Detailed information about a specific vault.
140///
141/// Returned by the `vaultDetails` info endpoint.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct HlVaultDetails {
145    /// Vault name.
146    pub name: String,
147    /// On-chain vault address.
148    pub vault_address: String,
149    /// Vault leader address.
150    #[serde(default)]
151    pub leader: Option<String>,
152    /// Portfolio state of the vault (positions, equity, etc.).
153    #[serde(default)]
154    pub portfolio: Option<serde_json::Value>,
155    /// Number of followers.
156    #[serde(default)]
157    pub follower_count: Option<u64>,
158    /// Any additional fields returned by the API.
159    #[serde(flatten)]
160    pub extra: HashMap<String, serde_json::Value>,
161}
162
163/// User fee information including maker/taker rates.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166#[non_exhaustive]
167pub struct HlUserFees {
168    /// Fee tier level.
169    pub fee_tier: String,
170    /// Maker fee rate (e.g. "0.0002").
171    pub maker_rate: Decimal,
172    /// Taker fee rate (e.g. "0.0005").
173    pub taker_rate: Decimal,
174}
175
176impl HlUserFees {
177    /// Creates a new `HlUserFees`.
178    pub fn new(fee_tier: String, maker_rate: Decimal, taker_rate: Decimal) -> Self {
179        Self {
180            fee_tier,
181            maker_rate,
182            taker_rate,
183        }
184    }
185}
186
187/// API rate limit status.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190#[non_exhaustive]
191pub struct HlRateLimitStatus {
192    /// Current number of requests used.
193    pub used: u64,
194    /// Maximum allowed requests in the window.
195    pub limit: u64,
196    /// Window duration in milliseconds.
197    pub window_ms: u64,
198}
199
200impl HlRateLimitStatus {
201    /// Creates a new `HlRateLimitStatus`.
202    pub fn new(used: u64, limit: u64, window_ms: u64) -> Self {
203        Self {
204            used,
205            limit,
206            window_ms,
207        }
208    }
209}
210
211/// An extra (sub-)agent approval entry.
212///
213/// Returned by the `extraAgents` info endpoint.
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215#[serde(rename_all = "camelCase")]
216pub struct HlExtraAgent {
217    /// Address of the approved agent.
218    pub address: String,
219    /// Human-readable agent name, if set.
220    #[serde(default)]
221    pub name: Option<String>,
222    /// Any additional fields returned by the API.
223    #[serde(flatten)]
224    pub extra: HashMap<String, serde_json::Value>,
225}
226
227/// A staking delegation.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "camelCase")]
230#[non_exhaustive]
231pub struct HlStakingDelegation {
232    /// Validator address.
233    pub validator: String,
234    /// Amount delegated.
235    pub amount: Decimal,
236    /// Pending rewards.
237    pub rewards: Decimal,
238}
239
240impl HlStakingDelegation {
241    /// Creates a new `HlStakingDelegation`.
242    pub fn new(validator: String, amount: Decimal, rewards: Decimal) -> Self {
243        Self {
244            validator,
245            amount,
246            rewards,
247        }
248    }
249}
250
251/// Borrow/lend position.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254#[non_exhaustive]
255pub struct HlBorrowLendState {
256    /// Token/coin name.
257    pub coin: String,
258    /// Amount supplied/lent.
259    pub supply: Decimal,
260    /// Amount borrowed.
261    pub borrow: Decimal,
262    /// Current APY rate.
263    pub apy: Decimal,
264}
265
266impl HlBorrowLendState {
267    /// Creates a new `HlBorrowLendState`.
268    pub fn new(coin: String, supply: Decimal, borrow: Decimal, apy: Decimal) -> Self {
269        Self {
270            coin,
271            supply,
272            borrow,
273            apy,
274        }
275    }
276}
277
278/// An open order on Hyperliquid.
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281#[non_exhaustive]
282pub struct HlOpenOrder {
283    /// Order ID.
284    pub oid: u64,
285    /// The coin/asset symbol.
286    pub coin: String,
287    /// Order side.
288    pub side: crate::market::TradeSide,
289    /// Limit price.
290    pub limit_px: Decimal,
291    /// Order size.
292    pub sz: Decimal,
293    /// Timestamp in milliseconds.
294    pub timestamp: u64,
295    /// Order type (e.g. "Limit").
296    pub order_type: String,
297    /// Client order ID, if set.
298    pub cloid: Option<String>,
299}
300
301impl HlOpenOrder {
302    /// Creates a new `HlOpenOrder`.
303    #[allow(clippy::too_many_arguments)]
304    pub fn new(
305        oid: u64,
306        coin: String,
307        side: crate::market::TradeSide,
308        limit_px: Decimal,
309        sz: Decimal,
310        timestamp: u64,
311        order_type: String,
312        cloid: Option<String>,
313    ) -> Self {
314        Self {
315            oid,
316            coin,
317            side,
318            limit_px,
319            sz,
320            timestamp,
321            order_type,
322            cloid,
323        }
324    }
325}
326
327/// An open order with the richer "frontend" metadata (trigger conditions,
328/// TP/SL bracket info, original size, children) returned by the
329/// `frontendOpenOrders` query — a superset of [`HlOpenOrder`].
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332#[non_exhaustive]
333pub struct HlFrontendOpenOrder {
334    /// Order ID.
335    pub oid: u64,
336    /// The coin/asset symbol.
337    pub coin: String,
338    /// Order side.
339    pub side: crate::market::TradeSide,
340    /// Limit price.
341    pub limit_px: Decimal,
342    /// Current remaining size.
343    pub sz: Decimal,
344    /// Original size when the order was placed.
345    pub orig_sz: Decimal,
346    /// Timestamp in milliseconds.
347    pub timestamp: u64,
348    /// Order type label (e.g. "Limit", "Stop Market", "Take Profit Limit").
349    pub order_type: String,
350    /// Time-in-force, if applicable (may be absent for trigger orders).
351    pub tif: Option<String>,
352    /// Whether this order only reduces an existing position.
353    pub reduce_only: bool,
354    /// Whether this is a trigger (stop/take-profit) order.
355    pub is_trigger: bool,
356    /// Whether this order is a position-level TP/SL.
357    pub is_position_tpsl: bool,
358    /// Human-readable trigger condition (e.g. "N/A" for non-trigger orders).
359    pub trigger_condition: String,
360    /// Trigger price (`0` for non-trigger orders).
361    pub trigger_px: Decimal,
362    /// Client order ID, if set.
363    pub cloid: Option<String>,
364    /// Child orders (e.g. the TP/SL legs of a bracket); empty when none.
365    pub children: Vec<HlFrontendOpenOrder>,
366}
367
368impl HlFrontendOpenOrder {
369    /// Creates a new `HlFrontendOpenOrder`.
370    #[allow(clippy::too_many_arguments)]
371    pub fn new(
372        oid: u64,
373        coin: String,
374        side: crate::market::TradeSide,
375        limit_px: Decimal,
376        sz: Decimal,
377        orig_sz: Decimal,
378        timestamp: u64,
379        order_type: String,
380        tif: Option<String>,
381        reduce_only: bool,
382        is_trigger: bool,
383        is_position_tpsl: bool,
384        trigger_condition: String,
385        trigger_px: Decimal,
386        cloid: Option<String>,
387        children: Vec<HlFrontendOpenOrder>,
388    ) -> Self {
389        Self {
390            oid,
391            coin,
392            side,
393            limit_px,
394            sz,
395            orig_sz,
396            timestamp,
397            order_type,
398            tif,
399            reduce_only,
400            is_trigger,
401            is_position_tpsl,
402            trigger_condition,
403            trigger_px,
404            cloid,
405            children,
406        }
407    }
408}
409
410/// Detailed status of a single order.
411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412#[serde(rename_all = "camelCase")]
413#[non_exhaustive]
414pub struct HlOrderDetail {
415    /// Order ID.
416    pub oid: u64,
417    /// The coin/asset symbol.
418    pub coin: String,
419    /// Order side.
420    pub side: crate::market::TradeSide,
421    /// Limit price.
422    pub limit_px: Decimal,
423    /// Order size.
424    pub sz: Decimal,
425    /// Timestamp in milliseconds.
426    pub timestamp: u64,
427    /// Order type (e.g. "Limit").
428    pub order_type: String,
429    /// Client order ID, if set.
430    pub cloid: Option<String>,
431    /// Order status (e.g. "open", "filled", "canceled").
432    pub status: String,
433}
434
435impl HlOrderDetail {
436    /// Creates a new `HlOrderDetail`.
437    #[allow(clippy::too_many_arguments)]
438    pub fn new(
439        oid: u64,
440        coin: String,
441        side: crate::market::TradeSide,
442        limit_px: Decimal,
443        sz: Decimal,
444        timestamp: u64,
445        order_type: String,
446        cloid: Option<String>,
447        status: String,
448    ) -> Self {
449        Self {
450            oid,
451            coin,
452            side,
453            limit_px,
454            sz,
455            timestamp,
456            order_type,
457            cloid,
458            status,
459        }
460    }
461}
462
463/// A funding rate entry for a coin.
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(rename_all = "camelCase")]
466#[non_exhaustive]
467pub struct HlFundingEntry {
468    /// The coin/asset symbol.
469    pub coin: String,
470    /// Funding rate.
471    pub funding_rate: Decimal,
472    /// Premium.
473    pub premium: Decimal,
474    /// Timestamp in milliseconds.
475    pub time: u64,
476}
477
478impl HlFundingEntry {
479    /// Creates a new `HlFundingEntry`.
480    pub fn new(coin: String, funding_rate: Decimal, premium: Decimal, time: u64) -> Self {
481        Self {
482            coin,
483            funding_rate,
484            premium,
485            time,
486        }
487    }
488}
489
490/// A user-specific funding entry.
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
492#[serde(rename_all = "camelCase")]
493#[non_exhaustive]
494pub struct HlUserFundingEntry {
495    /// The coin/asset symbol.
496    pub coin: String,
497    /// USDC amount.
498    pub usdc: Decimal,
499    /// Size (signed).
500    pub szi: Decimal,
501    /// Funding rate.
502    pub funding_rate: Decimal,
503    /// Timestamp in milliseconds.
504    pub time: u64,
505}
506
507impl HlUserFundingEntry {
508    /// Creates a new `HlUserFundingEntry`.
509    pub fn new(
510        coin: String,
511        usdc: Decimal,
512        szi: Decimal,
513        funding_rate: Decimal,
514        time: u64,
515    ) -> Self {
516        Self {
517            coin,
518            usdc,
519            szi,
520            funding_rate,
521            time,
522        }
523    }
524}
525
526/// A historical order with status.
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528#[serde(rename_all = "camelCase")]
529#[non_exhaustive]
530pub struct HlHistoricalOrder {
531    /// Order ID.
532    pub oid: u64,
533    /// The coin/asset symbol.
534    pub coin: String,
535    /// Order side.
536    pub side: crate::market::TradeSide,
537    /// Limit price.
538    pub limit_px: Decimal,
539    /// Order size.
540    pub sz: Decimal,
541    /// Timestamp in milliseconds.
542    pub timestamp: u64,
543    /// Order type (e.g. "Limit").
544    pub order_type: String,
545    /// Client order ID, if set.
546    pub cloid: Option<String>,
547    /// Order status (e.g. "filled", "canceled").
548    pub status: String,
549}
550
551impl HlHistoricalOrder {
552    /// Creates a new `HlHistoricalOrder`.
553    #[allow(clippy::too_many_arguments)]
554    pub fn new(
555        oid: u64,
556        coin: String,
557        side: crate::market::TradeSide,
558        limit_px: Decimal,
559        sz: Decimal,
560        timestamp: u64,
561        order_type: String,
562        cloid: Option<String>,
563        status: String,
564    ) -> Self {
565        Self {
566            oid,
567            coin,
568            side,
569            limit_px,
570            sz,
571            timestamp,
572            order_type,
573            cloid,
574            status,
575        }
576    }
577}
578
579/// Referral state for a Hyperliquid account.
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "camelCase")]
582#[non_exhaustive]
583pub struct HlReferralState {
584    /// The address of the referrer, if any.
585    pub referrer: Option<String>,
586    /// The user's own referral code, if any.
587    pub referral_code: Option<String>,
588    /// Cumulative volume traded.
589    pub cum_vlm: Decimal,
590    /// Referral rewards earned.
591    pub rewards: Decimal,
592}
593
594impl HlReferralState {
595    /// Creates a new `HlReferralState`.
596    pub fn new(
597        referrer: Option<String>,
598        referral_code: Option<String>,
599        cum_vlm: Decimal,
600        rewards: Decimal,
601    ) -> Self {
602        Self {
603            referrer,
604            referral_code,
605            cum_vlm,
606            rewards,
607        }
608    }
609}
610
611/// Active asset data for a user's position in a specific coin.
612#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
613#[serde(rename_all = "camelCase")]
614#[non_exhaustive]
615pub struct HlActiveAssetData {
616    /// The coin/asset symbol.
617    pub coin: String,
618    /// Current leverage for this asset.
619    pub leverage: Decimal,
620    /// Maximum trade sizes (buy/sell).
621    pub max_trade_szs: Vec<Decimal>,
622    /// Available to trade amounts (buy/sell).
623    pub available_to_trade: Vec<Decimal>,
624    /// Current mark price.
625    pub mark_px: Decimal,
626}
627
628impl HlActiveAssetData {
629    /// Creates a new `HlActiveAssetData`.
630    pub fn new(
631        coin: String,
632        leverage: Decimal,
633        max_trade_szs: Vec<Decimal>,
634        available_to_trade: Vec<Decimal>,
635        mark_px: Decimal,
636    ) -> Self {
637        Self {
638            coin,
639            leverage,
640            max_trade_szs,
641            available_to_trade,
642            mark_px,
643        }
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::market::TradeSide;
651    use std::str::FromStr;
652
653    #[test]
654    fn position_serde_roundtrip() {
655        let pos = HlPosition {
656            coin: "BTC".into(),
657            size: Decimal::from_str("0.5").unwrap(),
658            entry_px: Decimal::from_str("60000.0").unwrap(),
659            unrealized_pnl: Decimal::from_str("150.0").unwrap(),
660            leverage: Decimal::from_str("10.0").unwrap(),
661            liquidation_px: Some(Decimal::from_str("54000.0").unwrap()),
662        };
663        let json = serde_json::to_string(&pos).unwrap();
664        let parsed: HlPosition = serde_json::from_str(&json).unwrap();
665        assert_eq!(parsed.coin, "BTC");
666        assert_eq!(parsed.size, Decimal::from_str("0.5").unwrap());
667        assert_eq!(parsed.entry_px, Decimal::from_str("60000.0").unwrap());
668        assert_eq!(parsed.unrealized_pnl, Decimal::from_str("150.0").unwrap());
669        assert_eq!(parsed.leverage, Decimal::from_str("10.0").unwrap());
670        assert_eq!(
671            parsed.liquidation_px,
672            Some(Decimal::from_str("54000.0").unwrap())
673        );
674    }
675
676    #[test]
677    fn position_no_liquidation_px_roundtrip() {
678        let pos = HlPosition {
679            coin: "ETH".into(),
680            size: Decimal::from_str("-2.0").unwrap(),
681            entry_px: Decimal::from_str("3000.0").unwrap(),
682            unrealized_pnl: Decimal::from_str("-50.0").unwrap(),
683            leverage: Decimal::from_str("5.0").unwrap(),
684            liquidation_px: None,
685        };
686        let json = serde_json::to_string(&pos).unwrap();
687        let parsed: HlPosition = serde_json::from_str(&json).unwrap();
688        assert!(parsed.liquidation_px.is_none());
689        assert!(parsed.size < Decimal::ZERO);
690    }
691
692    #[test]
693    fn position_camel_case_keys() {
694        let pos = HlPosition {
695            coin: "X".into(),
696            size: Decimal::ONE,
697            entry_px: Decimal::ONE,
698            unrealized_pnl: Decimal::ZERO,
699            leverage: Decimal::ONE,
700            liquidation_px: None,
701        };
702        let json = serde_json::to_string(&pos).unwrap();
703        assert!(json.contains("entryPx"));
704        assert!(json.contains("unrealizedPnl"));
705        assert!(json.contains("liquidationPx"));
706    }
707
708    #[test]
709    fn fill_serde_roundtrip() {
710        let fill = HlFill {
711            coin: "ETH".into(),
712            px: Decimal::from_str("3000.0").unwrap(),
713            sz: Decimal::from_str("1.5").unwrap(),
714            is_buy: true,
715            timestamp: 1700000000000,
716            fee: Decimal::from_str("0.75").unwrap(),
717            closed_pnl: Decimal::ZERO,
718        };
719        let json = serde_json::to_string(&fill).unwrap();
720        let parsed: HlFill = serde_json::from_str(&json).unwrap();
721        assert_eq!(parsed.coin, "ETH");
722        assert_eq!(parsed.px, Decimal::from_str("3000.0").unwrap());
723        assert_eq!(parsed.sz, Decimal::from_str("1.5").unwrap());
724        assert!(parsed.is_buy);
725        assert_eq!(parsed.timestamp, 1700000000000);
726        assert_eq!(parsed.fee, Decimal::from_str("0.75").unwrap());
727        assert_eq!(parsed.closed_pnl, Decimal::ZERO);
728    }
729
730    #[test]
731    fn fill_camel_case_keys() {
732        let fill = HlFill {
733            coin: "X".into(),
734            px: Decimal::ONE,
735            sz: Decimal::ONE,
736            is_buy: false,
737            timestamp: 0,
738            fee: Decimal::ZERO,
739            closed_pnl: Decimal::from_str("100.0").unwrap(),
740        };
741        let json = serde_json::to_string(&fill).unwrap();
742        assert!(json.contains("isBuy"));
743        assert!(json.contains("closedPnl"));
744    }
745
746    #[test]
747    fn account_state_serde_roundtrip() {
748        let state = HlAccountState {
749            equity: Decimal::from_str("100000.0").unwrap(),
750            margin_available: Decimal::from_str("50000.0").unwrap(),
751            positions: vec![HlPosition {
752                coin: "BTC".into(),
753                size: Decimal::from_str("0.1").unwrap(),
754                entry_px: Decimal::from_str("60000.0").unwrap(),
755                unrealized_pnl: Decimal::ZERO,
756                leverage: Decimal::from_str("10.0").unwrap(),
757                liquidation_px: None,
758            }],
759        };
760        let json = serde_json::to_string(&state).unwrap();
761        let parsed: HlAccountState = serde_json::from_str(&json).unwrap();
762        assert_eq!(parsed.equity, Decimal::from_str("100000.0").unwrap());
763        assert_eq!(
764            parsed.margin_available,
765            Decimal::from_str("50000.0").unwrap()
766        );
767        assert_eq!(parsed.positions.len(), 1);
768        assert_eq!(parsed.positions[0].coin, "BTC");
769    }
770
771    #[test]
772    fn account_state_empty_positions_roundtrip() {
773        let state = HlAccountState {
774            equity: Decimal::ZERO,
775            margin_available: Decimal::ZERO,
776            positions: vec![],
777        };
778        let json = serde_json::to_string(&state).unwrap();
779        let parsed: HlAccountState = serde_json::from_str(&json).unwrap();
780        assert!(parsed.positions.is_empty());
781    }
782
783    #[test]
784    fn account_state_camel_case_keys() {
785        let state = HlAccountState {
786            equity: Decimal::ONE,
787            margin_available: Decimal::ONE,
788            positions: vec![],
789        };
790        let json = serde_json::to_string(&state).unwrap();
791        assert!(json.contains("marginAvailable"));
792    }
793
794    #[test]
795    fn vault_summary_serde_roundtrip() {
796        let json = serde_json::json!({
797            "vaultAddress": "0xabc123",
798            "name": "My Vault",
799            "leaderEquity": "10000.0",
800            "followerEquity": "50000.0",
801            "allTimePnl": "2500.0"
802        });
803        let parsed: HlVaultSummary = serde_json::from_value(json).unwrap();
804        assert_eq!(parsed.vault_address, "0xabc123");
805        assert_eq!(parsed.name, "My Vault");
806        assert_eq!(
807            parsed.leader_equity,
808            Some(Decimal::from_str("10000.0").unwrap())
809        );
810        assert_eq!(
811            parsed.follower_equity,
812            Some(Decimal::from_str("50000.0").unwrap())
813        );
814        assert_eq!(
815            parsed.all_time_pnl,
816            Some(Decimal::from_str("2500.0").unwrap())
817        );
818    }
819
820    #[test]
821    fn vault_summary_minimal_fields() {
822        let json = serde_json::json!({
823            "vaultAddress": "0xdef456",
824            "name": "Minimal Vault"
825        });
826        let parsed: HlVaultSummary = serde_json::from_value(json).unwrap();
827        assert_eq!(parsed.vault_address, "0xdef456");
828        assert_eq!(parsed.name, "Minimal Vault");
829        assert!(parsed.leader_equity.is_none());
830        assert!(parsed.follower_equity.is_none());
831        assert!(parsed.all_time_pnl.is_none());
832    }
833
834    #[test]
835    fn vault_summary_extra_fields_captured() {
836        let json = serde_json::json!({
837            "vaultAddress": "0x111",
838            "name": "V",
839            "someNewField": 42
840        });
841        let parsed: HlVaultSummary = serde_json::from_value(json).unwrap();
842        assert_eq!(
843            parsed.extra.get("someNewField").unwrap(),
844            &serde_json::json!(42)
845        );
846    }
847
848    #[test]
849    fn vault_summary_camel_case_keys() {
850        let summary = HlVaultSummary {
851            vault_address: "0x1".into(),
852            name: "V".into(),
853            leader_equity: Some(Decimal::ONE),
854            follower_equity: None,
855            all_time_pnl: None,
856            extra: HashMap::new(),
857        };
858        let json = serde_json::to_string(&summary).unwrap();
859        assert!(json.contains("vaultAddress"));
860        assert!(json.contains("leaderEquity"));
861    }
862
863    #[test]
864    fn vault_details_serde_roundtrip() {
865        let json = serde_json::json!({
866            "name": "Alpha Vault",
867            "vaultAddress": "0xvault",
868            "leader": "0xleader",
869            "portfolio": {"equity": "100000"},
870            "followerCount": 25
871        });
872        let parsed: HlVaultDetails = serde_json::from_value(json).unwrap();
873        assert_eq!(parsed.name, "Alpha Vault");
874        assert_eq!(parsed.vault_address, "0xvault");
875        assert_eq!(parsed.leader.as_deref(), Some("0xleader"));
876        assert!(parsed.portfolio.is_some());
877        assert_eq!(parsed.follower_count, Some(25));
878    }
879
880    #[test]
881    fn vault_details_minimal_fields() {
882        let json = serde_json::json!({
883            "name": "Min",
884            "vaultAddress": "0xmin"
885        });
886        let parsed: HlVaultDetails = serde_json::from_value(json).unwrap();
887        assert_eq!(parsed.name, "Min");
888        assert!(parsed.leader.is_none());
889        assert!(parsed.portfolio.is_none());
890        assert!(parsed.follower_count.is_none());
891    }
892
893    #[test]
894    fn vault_details_extra_fields_captured() {
895        let json = serde_json::json!({
896            "name": "V",
897            "vaultAddress": "0x1",
898            "customMetric": "hello"
899        });
900        let parsed: HlVaultDetails = serde_json::from_value(json).unwrap();
901        assert_eq!(
902            parsed.extra.get("customMetric").unwrap(),
903            &serde_json::json!("hello")
904        );
905    }
906
907    #[test]
908    fn extra_agent_serde_roundtrip() {
909        let json = serde_json::json!({
910            "address": "0xagent1",
911            "name": "Trading Bot"
912        });
913        let parsed: HlExtraAgent = serde_json::from_value(json).unwrap();
914        assert_eq!(parsed.address, "0xagent1");
915        assert_eq!(parsed.name.as_deref(), Some("Trading Bot"));
916    }
917
918    #[test]
919    fn extra_agent_minimal_fields() {
920        let json = serde_json::json!({
921            "address": "0xagent2"
922        });
923        let parsed: HlExtraAgent = serde_json::from_value(json).unwrap();
924        assert_eq!(parsed.address, "0xagent2");
925        assert!(parsed.name.is_none());
926    }
927
928    #[test]
929    fn extra_agent_extra_fields_captured() {
930        let json = serde_json::json!({
931            "address": "0xagent3",
932            "permissions": ["trade", "withdraw"]
933        });
934        let parsed: HlExtraAgent = serde_json::from_value(json).unwrap();
935        assert!(parsed.extra.contains_key("permissions"));
936    }
937
938    #[test]
939    fn staking_delegation_serde_roundtrip() {
940        let delegation = HlStakingDelegation {
941            validator: "0xval1".into(),
942            amount: Decimal::from_str("1000.0").unwrap(),
943            rewards: Decimal::from_str("5.25").unwrap(),
944        };
945        let json = serde_json::to_string(&delegation).unwrap();
946        let parsed: HlStakingDelegation = serde_json::from_str(&json).unwrap();
947        assert_eq!(parsed.validator, "0xval1");
948        assert_eq!(parsed.amount, Decimal::from_str("1000.0").unwrap());
949        assert_eq!(parsed.rewards, Decimal::from_str("5.25").unwrap());
950    }
951
952    #[test]
953    fn staking_delegation_from_json() {
954        let json = serde_json::json!({
955            "validator": "0xabc",
956            "amount": "500.0",
957            "rewards": "2.5"
958        });
959        let parsed: HlStakingDelegation = serde_json::from_value(json).unwrap();
960        assert_eq!(parsed.validator, "0xabc");
961        assert_eq!(parsed.amount, Decimal::from_str("500.0").unwrap());
962        assert_eq!(parsed.rewards, Decimal::from_str("2.5").unwrap());
963    }
964
965    #[test]
966    fn borrow_lend_state_serde_roundtrip() {
967        let state = HlBorrowLendState {
968            coin: "USDC".into(),
969            supply: Decimal::from_str("10000.0").unwrap(),
970            borrow: Decimal::from_str("5000.0").unwrap(),
971            apy: Decimal::from_str("0.05").unwrap(),
972        };
973        let json = serde_json::to_string(&state).unwrap();
974        let parsed: HlBorrowLendState = serde_json::from_str(&json).unwrap();
975        assert_eq!(parsed.coin, "USDC");
976        assert_eq!(parsed.supply, Decimal::from_str("10000.0").unwrap());
977        assert_eq!(parsed.borrow, Decimal::from_str("5000.0").unwrap());
978        assert_eq!(parsed.apy, Decimal::from_str("0.05").unwrap());
979    }
980
981    #[test]
982    fn borrow_lend_state_from_json() {
983        let json = serde_json::json!({
984            "coin": "ETH",
985            "supply": "100.0",
986            "borrow": "0.0",
987            "apy": "0.03"
988        });
989        let parsed: HlBorrowLendState = serde_json::from_value(json).unwrap();
990        assert_eq!(parsed.coin, "ETH");
991        assert_eq!(parsed.supply, Decimal::from_str("100.0").unwrap());
992        assert_eq!(parsed.borrow, Decimal::ZERO);
993        assert_eq!(parsed.apy, Decimal::from_str("0.03").unwrap());
994    }
995
996    #[test]
997    fn borrow_lend_state_camel_case_keys() {
998        let state = HlBorrowLendState {
999            coin: "X".into(),
1000            supply: Decimal::ONE,
1001            borrow: Decimal::ZERO,
1002            apy: Decimal::ZERO,
1003        };
1004        let json = serde_json::to_string(&state).unwrap();
1005        // All fields are single-word or camelCase; just verify it serializes
1006        assert!(json.contains("supply"));
1007        assert!(json.contains("borrow"));
1008        assert!(json.contains("apy"));
1009    }
1010
1011    #[test]
1012    fn extra_agent_camel_case_keys() {
1013        let agent = HlExtraAgent {
1014            address: "0x1".into(),
1015            name: Some("Bot".into()),
1016            extra: HashMap::new(),
1017        };
1018        let json = serde_json::to_string(&agent).unwrap();
1019        assert!(json.contains("address"));
1020        assert!(json.contains("name"));
1021    }
1022
1023    #[test]
1024    fn user_fees_serde_roundtrip() {
1025        let fees = HlUserFees {
1026            fee_tier: "VIP2".into(),
1027            maker_rate: Decimal::from_str("0.0001").unwrap(),
1028            taker_rate: Decimal::from_str("0.0003").unwrap(),
1029        };
1030        let json = serde_json::to_string(&fees).unwrap();
1031        let parsed: HlUserFees = serde_json::from_str(&json).unwrap();
1032        assert_eq!(parsed.fee_tier, "VIP2");
1033        assert_eq!(parsed.maker_rate, Decimal::from_str("0.0001").unwrap());
1034        assert_eq!(parsed.taker_rate, Decimal::from_str("0.0003").unwrap());
1035    }
1036
1037    #[test]
1038    fn user_fees_camel_case_keys() {
1039        let fees = HlUserFees {
1040            fee_tier: "T1".into(),
1041            maker_rate: Decimal::ZERO,
1042            taker_rate: Decimal::ONE,
1043        };
1044        let json = serde_json::to_string(&fees).unwrap();
1045        assert!(json.contains("feeTier"));
1046        assert!(json.contains("makerRate"));
1047        assert!(json.contains("takerRate"));
1048    }
1049
1050    #[test]
1051    fn user_fees_constructor() {
1052        let fees = HlUserFees::new(
1053            "VIP1".into(),
1054            Decimal::from_str("0.0002").unwrap(),
1055            Decimal::from_str("0.0005").unwrap(),
1056        );
1057        assert_eq!(fees.fee_tier, "VIP1");
1058        assert_eq!(fees.maker_rate, Decimal::from_str("0.0002").unwrap());
1059        assert_eq!(fees.taker_rate, Decimal::from_str("0.0005").unwrap());
1060    }
1061
1062    #[test]
1063    fn rate_limit_status_serde_roundtrip() {
1064        let status = HlRateLimitStatus {
1065            used: 42,
1066            limit: 1200,
1067            window_ms: 60000,
1068        };
1069        let json = serde_json::to_string(&status).unwrap();
1070        let parsed: HlRateLimitStatus = serde_json::from_str(&json).unwrap();
1071        assert_eq!(parsed.used, 42);
1072        assert_eq!(parsed.limit, 1200);
1073        assert_eq!(parsed.window_ms, 60000);
1074    }
1075
1076    #[test]
1077    fn rate_limit_status_camel_case_keys() {
1078        let status = HlRateLimitStatus {
1079            used: 0,
1080            limit: 100,
1081            window_ms: 30000,
1082        };
1083        let json = serde_json::to_string(&status).unwrap();
1084        assert!(json.contains("windowMs"));
1085    }
1086
1087    #[test]
1088    fn rate_limit_status_constructor() {
1089        let status = HlRateLimitStatus::new(10, 500, 60000);
1090        assert_eq!(status.used, 10);
1091        assert_eq!(status.limit, 500);
1092        assert_eq!(status.window_ms, 60000);
1093    }
1094
1095    #[test]
1096    fn open_order_serde_roundtrip() {
1097        let order = HlOpenOrder {
1098            oid: 12345,
1099            coin: "BTC".into(),
1100            side: TradeSide::Buy,
1101            limit_px: Decimal::from_str("60000.0").unwrap(),
1102            sz: Decimal::from_str("0.5").unwrap(),
1103            timestamp: 1700000000000,
1104            order_type: "Limit".into(),
1105            cloid: Some("my-order-1".into()),
1106        };
1107        let json = serde_json::to_string(&order).unwrap();
1108        let parsed: HlOpenOrder = serde_json::from_str(&json).unwrap();
1109        assert_eq!(parsed.oid, 12345);
1110        assert_eq!(parsed.coin, "BTC");
1111        assert_eq!(parsed.side, TradeSide::Buy);
1112        assert_eq!(parsed.limit_px, Decimal::from_str("60000.0").unwrap());
1113        assert_eq!(parsed.sz, Decimal::from_str("0.5").unwrap());
1114        assert_eq!(parsed.timestamp, 1700000000000);
1115        assert_eq!(parsed.order_type, "Limit");
1116        assert_eq!(parsed.cloid.as_deref(), Some("my-order-1"));
1117    }
1118
1119    #[test]
1120    fn open_order_no_cloid_roundtrip() {
1121        let order = HlOpenOrder {
1122            oid: 99,
1123            coin: "ETH".into(),
1124            side: TradeSide::Sell,
1125            limit_px: Decimal::from_str("3000.0").unwrap(),
1126            sz: Decimal::ONE,
1127            timestamp: 0,
1128            order_type: "Limit".into(),
1129            cloid: None,
1130        };
1131        let json = serde_json::to_string(&order).unwrap();
1132        let parsed: HlOpenOrder = serde_json::from_str(&json).unwrap();
1133        assert!(parsed.cloid.is_none());
1134    }
1135
1136    #[test]
1137    fn open_order_camel_case_keys() {
1138        let order = HlOpenOrder {
1139            oid: 1,
1140            coin: "X".into(),
1141            side: TradeSide::Buy,
1142            limit_px: Decimal::ONE,
1143            sz: Decimal::ONE,
1144            timestamp: 0,
1145            order_type: "Limit".into(),
1146            cloid: None,
1147        };
1148        let json = serde_json::to_string(&order).unwrap();
1149        assert!(json.contains("limitPx"));
1150        assert!(json.contains("orderType"));
1151    }
1152
1153    #[test]
1154    fn order_detail_serde_roundtrip() {
1155        let detail = HlOrderDetail {
1156            oid: 555,
1157            coin: "SOL".into(),
1158            side: TradeSide::Buy,
1159            limit_px: Decimal::from_str("150.0").unwrap(),
1160            sz: Decimal::from_str("10.0").unwrap(),
1161            timestamp: 1700000000000,
1162            order_type: "Limit".into(),
1163            cloid: None,
1164            status: "filled".into(),
1165        };
1166        let json = serde_json::to_string(&detail).unwrap();
1167        let parsed: HlOrderDetail = serde_json::from_str(&json).unwrap();
1168        assert_eq!(parsed.oid, 555);
1169        assert_eq!(parsed.status, "filled");
1170        assert_eq!(parsed.coin, "SOL");
1171    }
1172
1173    #[test]
1174    fn order_detail_camel_case_keys() {
1175        let detail = HlOrderDetail {
1176            oid: 1,
1177            coin: "X".into(),
1178            side: TradeSide::Buy,
1179            limit_px: Decimal::ONE,
1180            sz: Decimal::ONE,
1181            timestamp: 0,
1182            order_type: "Limit".into(),
1183            cloid: Some("c1".into()),
1184            status: "open".into(),
1185        };
1186        let json = serde_json::to_string(&detail).unwrap();
1187        assert!(json.contains("limitPx"));
1188        assert!(json.contains("orderType"));
1189    }
1190
1191    #[test]
1192    fn funding_entry_serde_roundtrip() {
1193        let entry = HlFundingEntry {
1194            coin: "BTC".into(),
1195            funding_rate: Decimal::from_str("0.0001").unwrap(),
1196            premium: Decimal::from_str("0.00005").unwrap(),
1197            time: 1700000000000,
1198        };
1199        let json = serde_json::to_string(&entry).unwrap();
1200        let parsed: HlFundingEntry = serde_json::from_str(&json).unwrap();
1201        assert_eq!(parsed.coin, "BTC");
1202        assert_eq!(parsed.funding_rate, Decimal::from_str("0.0001").unwrap());
1203        assert_eq!(parsed.premium, Decimal::from_str("0.00005").unwrap());
1204        assert_eq!(parsed.time, 1700000000000);
1205    }
1206
1207    #[test]
1208    fn funding_entry_camel_case_keys() {
1209        let entry = HlFundingEntry {
1210            coin: "X".into(),
1211            funding_rate: Decimal::ONE,
1212            premium: Decimal::ZERO,
1213            time: 0,
1214        };
1215        let json = serde_json::to_string(&entry).unwrap();
1216        assert!(json.contains("fundingRate"));
1217    }
1218
1219    #[test]
1220    fn user_funding_entry_serde_roundtrip() {
1221        let entry = HlUserFundingEntry {
1222            coin: "ETH".into(),
1223            usdc: Decimal::from_str("-1.5").unwrap(),
1224            szi: Decimal::from_str("2.0").unwrap(),
1225            funding_rate: Decimal::from_str("0.0002").unwrap(),
1226            time: 1700000000000,
1227        };
1228        let json = serde_json::to_string(&entry).unwrap();
1229        let parsed: HlUserFundingEntry = serde_json::from_str(&json).unwrap();
1230        assert_eq!(parsed.coin, "ETH");
1231        assert_eq!(parsed.usdc, Decimal::from_str("-1.5").unwrap());
1232        assert_eq!(parsed.szi, Decimal::from_str("2.0").unwrap());
1233        assert_eq!(parsed.funding_rate, Decimal::from_str("0.0002").unwrap());
1234        assert_eq!(parsed.time, 1700000000000);
1235    }
1236
1237    #[test]
1238    fn user_funding_entry_camel_case_keys() {
1239        let entry = HlUserFundingEntry {
1240            coin: "X".into(),
1241            usdc: Decimal::ONE,
1242            szi: Decimal::ONE,
1243            funding_rate: Decimal::ZERO,
1244            time: 0,
1245        };
1246        let json = serde_json::to_string(&entry).unwrap();
1247        assert!(json.contains("fundingRate"));
1248    }
1249
1250    #[test]
1251    fn historical_order_serde_roundtrip() {
1252        let order = HlHistoricalOrder {
1253            oid: 777,
1254            coin: "BTC".into(),
1255            side: TradeSide::Sell,
1256            limit_px: Decimal::from_str("65000.0").unwrap(),
1257            sz: Decimal::from_str("0.1").unwrap(),
1258            timestamp: 1700000000000,
1259            order_type: "Limit".into(),
1260            cloid: Some("hist-1".into()),
1261            status: "filled".into(),
1262        };
1263        let json = serde_json::to_string(&order).unwrap();
1264        let parsed: HlHistoricalOrder = serde_json::from_str(&json).unwrap();
1265        assert_eq!(parsed.oid, 777);
1266        assert_eq!(parsed.status, "filled");
1267        assert_eq!(parsed.coin, "BTC");
1268        assert_eq!(parsed.cloid.as_deref(), Some("hist-1"));
1269    }
1270
1271    #[test]
1272    fn historical_order_camel_case_keys() {
1273        let order = HlHistoricalOrder {
1274            oid: 1,
1275            coin: "X".into(),
1276            side: TradeSide::Buy,
1277            limit_px: Decimal::ONE,
1278            sz: Decimal::ONE,
1279            timestamp: 0,
1280            order_type: "Limit".into(),
1281            cloid: None,
1282            status: "canceled".into(),
1283        };
1284        let json = serde_json::to_string(&order).unwrap();
1285        assert!(json.contains("limitPx"));
1286        assert!(json.contains("orderType"));
1287    }
1288
1289    #[test]
1290    fn referral_state_serde_roundtrip() {
1291        let state = HlReferralState::new(
1292            Some("0xabc".into()),
1293            Some("CODE123".into()),
1294            Decimal::from_str("50000.0").unwrap(),
1295            Decimal::from_str("100.5").unwrap(),
1296        );
1297        let json = serde_json::to_string(&state).unwrap();
1298        let parsed: HlReferralState = serde_json::from_str(&json).unwrap();
1299        assert_eq!(parsed.referrer.as_deref(), Some("0xabc"));
1300        assert_eq!(parsed.referral_code.as_deref(), Some("CODE123"));
1301        assert_eq!(parsed.cum_vlm, Decimal::from_str("50000.0").unwrap());
1302        assert_eq!(parsed.rewards, Decimal::from_str("100.5").unwrap());
1303    }
1304
1305    #[test]
1306    fn referral_state_camel_case_keys() {
1307        let state = HlReferralState::new(None, None, Decimal::ZERO, Decimal::ZERO);
1308        let json = serde_json::to_string(&state).unwrap();
1309        assert!(json.contains("cumVlm"));
1310        assert!(json.contains("referralCode"));
1311    }
1312}