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/// Detailed status of a single order.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330#[non_exhaustive]
331pub struct HlOrderDetail {
332    /// Order ID.
333    pub oid: u64,
334    /// The coin/asset symbol.
335    pub coin: String,
336    /// Order side.
337    pub side: crate::market::TradeSide,
338    /// Limit price.
339    pub limit_px: Decimal,
340    /// Order size.
341    pub sz: Decimal,
342    /// Timestamp in milliseconds.
343    pub timestamp: u64,
344    /// Order type (e.g. "Limit").
345    pub order_type: String,
346    /// Client order ID, if set.
347    pub cloid: Option<String>,
348    /// Order status (e.g. "open", "filled", "canceled").
349    pub status: String,
350}
351
352impl HlOrderDetail {
353    /// Creates a new `HlOrderDetail`.
354    #[allow(clippy::too_many_arguments)]
355    pub fn new(
356        oid: u64,
357        coin: String,
358        side: crate::market::TradeSide,
359        limit_px: Decimal,
360        sz: Decimal,
361        timestamp: u64,
362        order_type: String,
363        cloid: Option<String>,
364        status: String,
365    ) -> Self {
366        Self {
367            oid,
368            coin,
369            side,
370            limit_px,
371            sz,
372            timestamp,
373            order_type,
374            cloid,
375            status,
376        }
377    }
378}
379
380/// A funding rate entry for a coin.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "camelCase")]
383#[non_exhaustive]
384pub struct HlFundingEntry {
385    /// The coin/asset symbol.
386    pub coin: String,
387    /// Funding rate.
388    pub funding_rate: Decimal,
389    /// Premium.
390    pub premium: Decimal,
391    /// Timestamp in milliseconds.
392    pub time: u64,
393}
394
395impl HlFundingEntry {
396    /// Creates a new `HlFundingEntry`.
397    pub fn new(coin: String, funding_rate: Decimal, premium: Decimal, time: u64) -> Self {
398        Self {
399            coin,
400            funding_rate,
401            premium,
402            time,
403        }
404    }
405}
406
407/// A user-specific funding entry.
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(rename_all = "camelCase")]
410#[non_exhaustive]
411pub struct HlUserFundingEntry {
412    /// The coin/asset symbol.
413    pub coin: String,
414    /// USDC amount.
415    pub usdc: Decimal,
416    /// Size (signed).
417    pub szi: Decimal,
418    /// Funding rate.
419    pub funding_rate: Decimal,
420    /// Timestamp in milliseconds.
421    pub time: u64,
422}
423
424impl HlUserFundingEntry {
425    /// Creates a new `HlUserFundingEntry`.
426    pub fn new(
427        coin: String,
428        usdc: Decimal,
429        szi: Decimal,
430        funding_rate: Decimal,
431        time: u64,
432    ) -> Self {
433        Self {
434            coin,
435            usdc,
436            szi,
437            funding_rate,
438            time,
439        }
440    }
441}
442
443/// A historical order with status.
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(rename_all = "camelCase")]
446#[non_exhaustive]
447pub struct HlHistoricalOrder {
448    /// Order ID.
449    pub oid: u64,
450    /// The coin/asset symbol.
451    pub coin: String,
452    /// Order side.
453    pub side: crate::market::TradeSide,
454    /// Limit price.
455    pub limit_px: Decimal,
456    /// Order size.
457    pub sz: Decimal,
458    /// Timestamp in milliseconds.
459    pub timestamp: u64,
460    /// Order type (e.g. "Limit").
461    pub order_type: String,
462    /// Client order ID, if set.
463    pub cloid: Option<String>,
464    /// Order status (e.g. "filled", "canceled").
465    pub status: String,
466}
467
468impl HlHistoricalOrder {
469    /// Creates a new `HlHistoricalOrder`.
470    #[allow(clippy::too_many_arguments)]
471    pub fn new(
472        oid: u64,
473        coin: String,
474        side: crate::market::TradeSide,
475        limit_px: Decimal,
476        sz: Decimal,
477        timestamp: u64,
478        order_type: String,
479        cloid: Option<String>,
480        status: String,
481    ) -> Self {
482        Self {
483            oid,
484            coin,
485            side,
486            limit_px,
487            sz,
488            timestamp,
489            order_type,
490            cloid,
491            status,
492        }
493    }
494}
495
496/// Referral state for a Hyperliquid account.
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498#[serde(rename_all = "camelCase")]
499#[non_exhaustive]
500pub struct HlReferralState {
501    /// The address of the referrer, if any.
502    pub referrer: Option<String>,
503    /// The user's own referral code, if any.
504    pub referral_code: Option<String>,
505    /// Cumulative volume traded.
506    pub cum_vlm: Decimal,
507    /// Referral rewards earned.
508    pub rewards: Decimal,
509}
510
511impl HlReferralState {
512    /// Creates a new `HlReferralState`.
513    pub fn new(
514        referrer: Option<String>,
515        referral_code: Option<String>,
516        cum_vlm: Decimal,
517        rewards: Decimal,
518    ) -> Self {
519        Self {
520            referrer,
521            referral_code,
522            cum_vlm,
523            rewards,
524        }
525    }
526}
527
528/// Active asset data for a user's position in a specific coin.
529#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
530#[serde(rename_all = "camelCase")]
531#[non_exhaustive]
532pub struct HlActiveAssetData {
533    /// The coin/asset symbol.
534    pub coin: String,
535    /// Current leverage for this asset.
536    pub leverage: Decimal,
537    /// Maximum trade sizes (buy/sell).
538    pub max_trade_szs: Vec<Decimal>,
539    /// Available to trade amounts (buy/sell).
540    pub available_to_trade: Vec<Decimal>,
541    /// Current mark price.
542    pub mark_px: Decimal,
543}
544
545impl HlActiveAssetData {
546    /// Creates a new `HlActiveAssetData`.
547    pub fn new(
548        coin: String,
549        leverage: Decimal,
550        max_trade_szs: Vec<Decimal>,
551        available_to_trade: Vec<Decimal>,
552        mark_px: Decimal,
553    ) -> Self {
554        Self {
555            coin,
556            leverage,
557            max_trade_szs,
558            available_to_trade,
559            mark_px,
560        }
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use crate::market::TradeSide;
568    use std::str::FromStr;
569
570    #[test]
571    fn position_serde_roundtrip() {
572        let pos = HlPosition {
573            coin: "BTC".into(),
574            size: Decimal::from_str("0.5").unwrap(),
575            entry_px: Decimal::from_str("60000.0").unwrap(),
576            unrealized_pnl: Decimal::from_str("150.0").unwrap(),
577            leverage: Decimal::from_str("10.0").unwrap(),
578            liquidation_px: Some(Decimal::from_str("54000.0").unwrap()),
579        };
580        let json = serde_json::to_string(&pos).unwrap();
581        let parsed: HlPosition = serde_json::from_str(&json).unwrap();
582        assert_eq!(parsed.coin, "BTC");
583        assert_eq!(parsed.size, Decimal::from_str("0.5").unwrap());
584        assert_eq!(parsed.entry_px, Decimal::from_str("60000.0").unwrap());
585        assert_eq!(parsed.unrealized_pnl, Decimal::from_str("150.0").unwrap());
586        assert_eq!(parsed.leverage, Decimal::from_str("10.0").unwrap());
587        assert_eq!(
588            parsed.liquidation_px,
589            Some(Decimal::from_str("54000.0").unwrap())
590        );
591    }
592
593    #[test]
594    fn position_no_liquidation_px_roundtrip() {
595        let pos = HlPosition {
596            coin: "ETH".into(),
597            size: Decimal::from_str("-2.0").unwrap(),
598            entry_px: Decimal::from_str("3000.0").unwrap(),
599            unrealized_pnl: Decimal::from_str("-50.0").unwrap(),
600            leverage: Decimal::from_str("5.0").unwrap(),
601            liquidation_px: None,
602        };
603        let json = serde_json::to_string(&pos).unwrap();
604        let parsed: HlPosition = serde_json::from_str(&json).unwrap();
605        assert!(parsed.liquidation_px.is_none());
606        assert!(parsed.size < Decimal::ZERO);
607    }
608
609    #[test]
610    fn position_camel_case_keys() {
611        let pos = HlPosition {
612            coin: "X".into(),
613            size: Decimal::ONE,
614            entry_px: Decimal::ONE,
615            unrealized_pnl: Decimal::ZERO,
616            leverage: Decimal::ONE,
617            liquidation_px: None,
618        };
619        let json = serde_json::to_string(&pos).unwrap();
620        assert!(json.contains("entryPx"));
621        assert!(json.contains("unrealizedPnl"));
622        assert!(json.contains("liquidationPx"));
623    }
624
625    #[test]
626    fn fill_serde_roundtrip() {
627        let fill = HlFill {
628            coin: "ETH".into(),
629            px: Decimal::from_str("3000.0").unwrap(),
630            sz: Decimal::from_str("1.5").unwrap(),
631            is_buy: true,
632            timestamp: 1700000000000,
633            fee: Decimal::from_str("0.75").unwrap(),
634            closed_pnl: Decimal::ZERO,
635        };
636        let json = serde_json::to_string(&fill).unwrap();
637        let parsed: HlFill = serde_json::from_str(&json).unwrap();
638        assert_eq!(parsed.coin, "ETH");
639        assert_eq!(parsed.px, Decimal::from_str("3000.0").unwrap());
640        assert_eq!(parsed.sz, Decimal::from_str("1.5").unwrap());
641        assert!(parsed.is_buy);
642        assert_eq!(parsed.timestamp, 1700000000000);
643        assert_eq!(parsed.fee, Decimal::from_str("0.75").unwrap());
644        assert_eq!(parsed.closed_pnl, Decimal::ZERO);
645    }
646
647    #[test]
648    fn fill_camel_case_keys() {
649        let fill = HlFill {
650            coin: "X".into(),
651            px: Decimal::ONE,
652            sz: Decimal::ONE,
653            is_buy: false,
654            timestamp: 0,
655            fee: Decimal::ZERO,
656            closed_pnl: Decimal::from_str("100.0").unwrap(),
657        };
658        let json = serde_json::to_string(&fill).unwrap();
659        assert!(json.contains("isBuy"));
660        assert!(json.contains("closedPnl"));
661    }
662
663    #[test]
664    fn account_state_serde_roundtrip() {
665        let state = HlAccountState {
666            equity: Decimal::from_str("100000.0").unwrap(),
667            margin_available: Decimal::from_str("50000.0").unwrap(),
668            positions: vec![HlPosition {
669                coin: "BTC".into(),
670                size: Decimal::from_str("0.1").unwrap(),
671                entry_px: Decimal::from_str("60000.0").unwrap(),
672                unrealized_pnl: Decimal::ZERO,
673                leverage: Decimal::from_str("10.0").unwrap(),
674                liquidation_px: None,
675            }],
676        };
677        let json = serde_json::to_string(&state).unwrap();
678        let parsed: HlAccountState = serde_json::from_str(&json).unwrap();
679        assert_eq!(parsed.equity, Decimal::from_str("100000.0").unwrap());
680        assert_eq!(
681            parsed.margin_available,
682            Decimal::from_str("50000.0").unwrap()
683        );
684        assert_eq!(parsed.positions.len(), 1);
685        assert_eq!(parsed.positions[0].coin, "BTC");
686    }
687
688    #[test]
689    fn account_state_empty_positions_roundtrip() {
690        let state = HlAccountState {
691            equity: Decimal::ZERO,
692            margin_available: Decimal::ZERO,
693            positions: vec![],
694        };
695        let json = serde_json::to_string(&state).unwrap();
696        let parsed: HlAccountState = serde_json::from_str(&json).unwrap();
697        assert!(parsed.positions.is_empty());
698    }
699
700    #[test]
701    fn account_state_camel_case_keys() {
702        let state = HlAccountState {
703            equity: Decimal::ONE,
704            margin_available: Decimal::ONE,
705            positions: vec![],
706        };
707        let json = serde_json::to_string(&state).unwrap();
708        assert!(json.contains("marginAvailable"));
709    }
710
711    #[test]
712    fn vault_summary_serde_roundtrip() {
713        let json = serde_json::json!({
714            "vaultAddress": "0xabc123",
715            "name": "My Vault",
716            "leaderEquity": "10000.0",
717            "followerEquity": "50000.0",
718            "allTimePnl": "2500.0"
719        });
720        let parsed: HlVaultSummary = serde_json::from_value(json).unwrap();
721        assert_eq!(parsed.vault_address, "0xabc123");
722        assert_eq!(parsed.name, "My Vault");
723        assert_eq!(
724            parsed.leader_equity,
725            Some(Decimal::from_str("10000.0").unwrap())
726        );
727        assert_eq!(
728            parsed.follower_equity,
729            Some(Decimal::from_str("50000.0").unwrap())
730        );
731        assert_eq!(
732            parsed.all_time_pnl,
733            Some(Decimal::from_str("2500.0").unwrap())
734        );
735    }
736
737    #[test]
738    fn vault_summary_minimal_fields() {
739        let json = serde_json::json!({
740            "vaultAddress": "0xdef456",
741            "name": "Minimal Vault"
742        });
743        let parsed: HlVaultSummary = serde_json::from_value(json).unwrap();
744        assert_eq!(parsed.vault_address, "0xdef456");
745        assert_eq!(parsed.name, "Minimal Vault");
746        assert!(parsed.leader_equity.is_none());
747        assert!(parsed.follower_equity.is_none());
748        assert!(parsed.all_time_pnl.is_none());
749    }
750
751    #[test]
752    fn vault_summary_extra_fields_captured() {
753        let json = serde_json::json!({
754            "vaultAddress": "0x111",
755            "name": "V",
756            "someNewField": 42
757        });
758        let parsed: HlVaultSummary = serde_json::from_value(json).unwrap();
759        assert_eq!(
760            parsed.extra.get("someNewField").unwrap(),
761            &serde_json::json!(42)
762        );
763    }
764
765    #[test]
766    fn vault_summary_camel_case_keys() {
767        let summary = HlVaultSummary {
768            vault_address: "0x1".into(),
769            name: "V".into(),
770            leader_equity: Some(Decimal::ONE),
771            follower_equity: None,
772            all_time_pnl: None,
773            extra: HashMap::new(),
774        };
775        let json = serde_json::to_string(&summary).unwrap();
776        assert!(json.contains("vaultAddress"));
777        assert!(json.contains("leaderEquity"));
778    }
779
780    #[test]
781    fn vault_details_serde_roundtrip() {
782        let json = serde_json::json!({
783            "name": "Alpha Vault",
784            "vaultAddress": "0xvault",
785            "leader": "0xleader",
786            "portfolio": {"equity": "100000"},
787            "followerCount": 25
788        });
789        let parsed: HlVaultDetails = serde_json::from_value(json).unwrap();
790        assert_eq!(parsed.name, "Alpha Vault");
791        assert_eq!(parsed.vault_address, "0xvault");
792        assert_eq!(parsed.leader.as_deref(), Some("0xleader"));
793        assert!(parsed.portfolio.is_some());
794        assert_eq!(parsed.follower_count, Some(25));
795    }
796
797    #[test]
798    fn vault_details_minimal_fields() {
799        let json = serde_json::json!({
800            "name": "Min",
801            "vaultAddress": "0xmin"
802        });
803        let parsed: HlVaultDetails = serde_json::from_value(json).unwrap();
804        assert_eq!(parsed.name, "Min");
805        assert!(parsed.leader.is_none());
806        assert!(parsed.portfolio.is_none());
807        assert!(parsed.follower_count.is_none());
808    }
809
810    #[test]
811    fn vault_details_extra_fields_captured() {
812        let json = serde_json::json!({
813            "name": "V",
814            "vaultAddress": "0x1",
815            "customMetric": "hello"
816        });
817        let parsed: HlVaultDetails = serde_json::from_value(json).unwrap();
818        assert_eq!(
819            parsed.extra.get("customMetric").unwrap(),
820            &serde_json::json!("hello")
821        );
822    }
823
824    #[test]
825    fn extra_agent_serde_roundtrip() {
826        let json = serde_json::json!({
827            "address": "0xagent1",
828            "name": "Trading Bot"
829        });
830        let parsed: HlExtraAgent = serde_json::from_value(json).unwrap();
831        assert_eq!(parsed.address, "0xagent1");
832        assert_eq!(parsed.name.as_deref(), Some("Trading Bot"));
833    }
834
835    #[test]
836    fn extra_agent_minimal_fields() {
837        let json = serde_json::json!({
838            "address": "0xagent2"
839        });
840        let parsed: HlExtraAgent = serde_json::from_value(json).unwrap();
841        assert_eq!(parsed.address, "0xagent2");
842        assert!(parsed.name.is_none());
843    }
844
845    #[test]
846    fn extra_agent_extra_fields_captured() {
847        let json = serde_json::json!({
848            "address": "0xagent3",
849            "permissions": ["trade", "withdraw"]
850        });
851        let parsed: HlExtraAgent = serde_json::from_value(json).unwrap();
852        assert!(parsed.extra.contains_key("permissions"));
853    }
854
855    #[test]
856    fn staking_delegation_serde_roundtrip() {
857        let delegation = HlStakingDelegation {
858            validator: "0xval1".into(),
859            amount: Decimal::from_str("1000.0").unwrap(),
860            rewards: Decimal::from_str("5.25").unwrap(),
861        };
862        let json = serde_json::to_string(&delegation).unwrap();
863        let parsed: HlStakingDelegation = serde_json::from_str(&json).unwrap();
864        assert_eq!(parsed.validator, "0xval1");
865        assert_eq!(parsed.amount, Decimal::from_str("1000.0").unwrap());
866        assert_eq!(parsed.rewards, Decimal::from_str("5.25").unwrap());
867    }
868
869    #[test]
870    fn staking_delegation_from_json() {
871        let json = serde_json::json!({
872            "validator": "0xabc",
873            "amount": "500.0",
874            "rewards": "2.5"
875        });
876        let parsed: HlStakingDelegation = serde_json::from_value(json).unwrap();
877        assert_eq!(parsed.validator, "0xabc");
878        assert_eq!(parsed.amount, Decimal::from_str("500.0").unwrap());
879        assert_eq!(parsed.rewards, Decimal::from_str("2.5").unwrap());
880    }
881
882    #[test]
883    fn borrow_lend_state_serde_roundtrip() {
884        let state = HlBorrowLendState {
885            coin: "USDC".into(),
886            supply: Decimal::from_str("10000.0").unwrap(),
887            borrow: Decimal::from_str("5000.0").unwrap(),
888            apy: Decimal::from_str("0.05").unwrap(),
889        };
890        let json = serde_json::to_string(&state).unwrap();
891        let parsed: HlBorrowLendState = serde_json::from_str(&json).unwrap();
892        assert_eq!(parsed.coin, "USDC");
893        assert_eq!(parsed.supply, Decimal::from_str("10000.0").unwrap());
894        assert_eq!(parsed.borrow, Decimal::from_str("5000.0").unwrap());
895        assert_eq!(parsed.apy, Decimal::from_str("0.05").unwrap());
896    }
897
898    #[test]
899    fn borrow_lend_state_from_json() {
900        let json = serde_json::json!({
901            "coin": "ETH",
902            "supply": "100.0",
903            "borrow": "0.0",
904            "apy": "0.03"
905        });
906        let parsed: HlBorrowLendState = serde_json::from_value(json).unwrap();
907        assert_eq!(parsed.coin, "ETH");
908        assert_eq!(parsed.supply, Decimal::from_str("100.0").unwrap());
909        assert_eq!(parsed.borrow, Decimal::ZERO);
910        assert_eq!(parsed.apy, Decimal::from_str("0.03").unwrap());
911    }
912
913    #[test]
914    fn borrow_lend_state_camel_case_keys() {
915        let state = HlBorrowLendState {
916            coin: "X".into(),
917            supply: Decimal::ONE,
918            borrow: Decimal::ZERO,
919            apy: Decimal::ZERO,
920        };
921        let json = serde_json::to_string(&state).unwrap();
922        // All fields are single-word or camelCase; just verify it serializes
923        assert!(json.contains("supply"));
924        assert!(json.contains("borrow"));
925        assert!(json.contains("apy"));
926    }
927
928    #[test]
929    fn extra_agent_camel_case_keys() {
930        let agent = HlExtraAgent {
931            address: "0x1".into(),
932            name: Some("Bot".into()),
933            extra: HashMap::new(),
934        };
935        let json = serde_json::to_string(&agent).unwrap();
936        assert!(json.contains("address"));
937        assert!(json.contains("name"));
938    }
939
940    #[test]
941    fn user_fees_serde_roundtrip() {
942        let fees = HlUserFees {
943            fee_tier: "VIP2".into(),
944            maker_rate: Decimal::from_str("0.0001").unwrap(),
945            taker_rate: Decimal::from_str("0.0003").unwrap(),
946        };
947        let json = serde_json::to_string(&fees).unwrap();
948        let parsed: HlUserFees = serde_json::from_str(&json).unwrap();
949        assert_eq!(parsed.fee_tier, "VIP2");
950        assert_eq!(parsed.maker_rate, Decimal::from_str("0.0001").unwrap());
951        assert_eq!(parsed.taker_rate, Decimal::from_str("0.0003").unwrap());
952    }
953
954    #[test]
955    fn user_fees_camel_case_keys() {
956        let fees = HlUserFees {
957            fee_tier: "T1".into(),
958            maker_rate: Decimal::ZERO,
959            taker_rate: Decimal::ONE,
960        };
961        let json = serde_json::to_string(&fees).unwrap();
962        assert!(json.contains("feeTier"));
963        assert!(json.contains("makerRate"));
964        assert!(json.contains("takerRate"));
965    }
966
967    #[test]
968    fn user_fees_constructor() {
969        let fees = HlUserFees::new(
970            "VIP1".into(),
971            Decimal::from_str("0.0002").unwrap(),
972            Decimal::from_str("0.0005").unwrap(),
973        );
974        assert_eq!(fees.fee_tier, "VIP1");
975        assert_eq!(fees.maker_rate, Decimal::from_str("0.0002").unwrap());
976        assert_eq!(fees.taker_rate, Decimal::from_str("0.0005").unwrap());
977    }
978
979    #[test]
980    fn rate_limit_status_serde_roundtrip() {
981        let status = HlRateLimitStatus {
982            used: 42,
983            limit: 1200,
984            window_ms: 60000,
985        };
986        let json = serde_json::to_string(&status).unwrap();
987        let parsed: HlRateLimitStatus = serde_json::from_str(&json).unwrap();
988        assert_eq!(parsed.used, 42);
989        assert_eq!(parsed.limit, 1200);
990        assert_eq!(parsed.window_ms, 60000);
991    }
992
993    #[test]
994    fn rate_limit_status_camel_case_keys() {
995        let status = HlRateLimitStatus {
996            used: 0,
997            limit: 100,
998            window_ms: 30000,
999        };
1000        let json = serde_json::to_string(&status).unwrap();
1001        assert!(json.contains("windowMs"));
1002    }
1003
1004    #[test]
1005    fn rate_limit_status_constructor() {
1006        let status = HlRateLimitStatus::new(10, 500, 60000);
1007        assert_eq!(status.used, 10);
1008        assert_eq!(status.limit, 500);
1009        assert_eq!(status.window_ms, 60000);
1010    }
1011
1012    #[test]
1013    fn open_order_serde_roundtrip() {
1014        let order = HlOpenOrder {
1015            oid: 12345,
1016            coin: "BTC".into(),
1017            side: TradeSide::Buy,
1018            limit_px: Decimal::from_str("60000.0").unwrap(),
1019            sz: Decimal::from_str("0.5").unwrap(),
1020            timestamp: 1700000000000,
1021            order_type: "Limit".into(),
1022            cloid: Some("my-order-1".into()),
1023        };
1024        let json = serde_json::to_string(&order).unwrap();
1025        let parsed: HlOpenOrder = serde_json::from_str(&json).unwrap();
1026        assert_eq!(parsed.oid, 12345);
1027        assert_eq!(parsed.coin, "BTC");
1028        assert_eq!(parsed.side, TradeSide::Buy);
1029        assert_eq!(parsed.limit_px, Decimal::from_str("60000.0").unwrap());
1030        assert_eq!(parsed.sz, Decimal::from_str("0.5").unwrap());
1031        assert_eq!(parsed.timestamp, 1700000000000);
1032        assert_eq!(parsed.order_type, "Limit");
1033        assert_eq!(parsed.cloid.as_deref(), Some("my-order-1"));
1034    }
1035
1036    #[test]
1037    fn open_order_no_cloid_roundtrip() {
1038        let order = HlOpenOrder {
1039            oid: 99,
1040            coin: "ETH".into(),
1041            side: TradeSide::Sell,
1042            limit_px: Decimal::from_str("3000.0").unwrap(),
1043            sz: Decimal::ONE,
1044            timestamp: 0,
1045            order_type: "Limit".into(),
1046            cloid: None,
1047        };
1048        let json = serde_json::to_string(&order).unwrap();
1049        let parsed: HlOpenOrder = serde_json::from_str(&json).unwrap();
1050        assert!(parsed.cloid.is_none());
1051    }
1052
1053    #[test]
1054    fn open_order_camel_case_keys() {
1055        let order = HlOpenOrder {
1056            oid: 1,
1057            coin: "X".into(),
1058            side: TradeSide::Buy,
1059            limit_px: Decimal::ONE,
1060            sz: Decimal::ONE,
1061            timestamp: 0,
1062            order_type: "Limit".into(),
1063            cloid: None,
1064        };
1065        let json = serde_json::to_string(&order).unwrap();
1066        assert!(json.contains("limitPx"));
1067        assert!(json.contains("orderType"));
1068    }
1069
1070    #[test]
1071    fn order_detail_serde_roundtrip() {
1072        let detail = HlOrderDetail {
1073            oid: 555,
1074            coin: "SOL".into(),
1075            side: TradeSide::Buy,
1076            limit_px: Decimal::from_str("150.0").unwrap(),
1077            sz: Decimal::from_str("10.0").unwrap(),
1078            timestamp: 1700000000000,
1079            order_type: "Limit".into(),
1080            cloid: None,
1081            status: "filled".into(),
1082        };
1083        let json = serde_json::to_string(&detail).unwrap();
1084        let parsed: HlOrderDetail = serde_json::from_str(&json).unwrap();
1085        assert_eq!(parsed.oid, 555);
1086        assert_eq!(parsed.status, "filled");
1087        assert_eq!(parsed.coin, "SOL");
1088    }
1089
1090    #[test]
1091    fn order_detail_camel_case_keys() {
1092        let detail = HlOrderDetail {
1093            oid: 1,
1094            coin: "X".into(),
1095            side: TradeSide::Buy,
1096            limit_px: Decimal::ONE,
1097            sz: Decimal::ONE,
1098            timestamp: 0,
1099            order_type: "Limit".into(),
1100            cloid: Some("c1".into()),
1101            status: "open".into(),
1102        };
1103        let json = serde_json::to_string(&detail).unwrap();
1104        assert!(json.contains("limitPx"));
1105        assert!(json.contains("orderType"));
1106    }
1107
1108    #[test]
1109    fn funding_entry_serde_roundtrip() {
1110        let entry = HlFundingEntry {
1111            coin: "BTC".into(),
1112            funding_rate: Decimal::from_str("0.0001").unwrap(),
1113            premium: Decimal::from_str("0.00005").unwrap(),
1114            time: 1700000000000,
1115        };
1116        let json = serde_json::to_string(&entry).unwrap();
1117        let parsed: HlFundingEntry = serde_json::from_str(&json).unwrap();
1118        assert_eq!(parsed.coin, "BTC");
1119        assert_eq!(parsed.funding_rate, Decimal::from_str("0.0001").unwrap());
1120        assert_eq!(parsed.premium, Decimal::from_str("0.00005").unwrap());
1121        assert_eq!(parsed.time, 1700000000000);
1122    }
1123
1124    #[test]
1125    fn funding_entry_camel_case_keys() {
1126        let entry = HlFundingEntry {
1127            coin: "X".into(),
1128            funding_rate: Decimal::ONE,
1129            premium: Decimal::ZERO,
1130            time: 0,
1131        };
1132        let json = serde_json::to_string(&entry).unwrap();
1133        assert!(json.contains("fundingRate"));
1134    }
1135
1136    #[test]
1137    fn user_funding_entry_serde_roundtrip() {
1138        let entry = HlUserFundingEntry {
1139            coin: "ETH".into(),
1140            usdc: Decimal::from_str("-1.5").unwrap(),
1141            szi: Decimal::from_str("2.0").unwrap(),
1142            funding_rate: Decimal::from_str("0.0002").unwrap(),
1143            time: 1700000000000,
1144        };
1145        let json = serde_json::to_string(&entry).unwrap();
1146        let parsed: HlUserFundingEntry = serde_json::from_str(&json).unwrap();
1147        assert_eq!(parsed.coin, "ETH");
1148        assert_eq!(parsed.usdc, Decimal::from_str("-1.5").unwrap());
1149        assert_eq!(parsed.szi, Decimal::from_str("2.0").unwrap());
1150        assert_eq!(parsed.funding_rate, Decimal::from_str("0.0002").unwrap());
1151        assert_eq!(parsed.time, 1700000000000);
1152    }
1153
1154    #[test]
1155    fn user_funding_entry_camel_case_keys() {
1156        let entry = HlUserFundingEntry {
1157            coin: "X".into(),
1158            usdc: Decimal::ONE,
1159            szi: Decimal::ONE,
1160            funding_rate: Decimal::ZERO,
1161            time: 0,
1162        };
1163        let json = serde_json::to_string(&entry).unwrap();
1164        assert!(json.contains("fundingRate"));
1165    }
1166
1167    #[test]
1168    fn historical_order_serde_roundtrip() {
1169        let order = HlHistoricalOrder {
1170            oid: 777,
1171            coin: "BTC".into(),
1172            side: TradeSide::Sell,
1173            limit_px: Decimal::from_str("65000.0").unwrap(),
1174            sz: Decimal::from_str("0.1").unwrap(),
1175            timestamp: 1700000000000,
1176            order_type: "Limit".into(),
1177            cloid: Some("hist-1".into()),
1178            status: "filled".into(),
1179        };
1180        let json = serde_json::to_string(&order).unwrap();
1181        let parsed: HlHistoricalOrder = serde_json::from_str(&json).unwrap();
1182        assert_eq!(parsed.oid, 777);
1183        assert_eq!(parsed.status, "filled");
1184        assert_eq!(parsed.coin, "BTC");
1185        assert_eq!(parsed.cloid.as_deref(), Some("hist-1"));
1186    }
1187
1188    #[test]
1189    fn historical_order_camel_case_keys() {
1190        let order = HlHistoricalOrder {
1191            oid: 1,
1192            coin: "X".into(),
1193            side: TradeSide::Buy,
1194            limit_px: Decimal::ONE,
1195            sz: Decimal::ONE,
1196            timestamp: 0,
1197            order_type: "Limit".into(),
1198            cloid: None,
1199            status: "canceled".into(),
1200        };
1201        let json = serde_json::to_string(&order).unwrap();
1202        assert!(json.contains("limitPx"));
1203        assert!(json.contains("orderType"));
1204    }
1205
1206    #[test]
1207    fn referral_state_serde_roundtrip() {
1208        let state = HlReferralState::new(
1209            Some("0xabc".into()),
1210            Some("CODE123".into()),
1211            Decimal::from_str("50000.0").unwrap(),
1212            Decimal::from_str("100.5").unwrap(),
1213        );
1214        let json = serde_json::to_string(&state).unwrap();
1215        let parsed: HlReferralState = serde_json::from_str(&json).unwrap();
1216        assert_eq!(parsed.referrer.as_deref(), Some("0xabc"));
1217        assert_eq!(parsed.referral_code.as_deref(), Some("CODE123"));
1218        assert_eq!(parsed.cum_vlm, Decimal::from_str("50000.0").unwrap());
1219        assert_eq!(parsed.rewards, Decimal::from_str("100.5").unwrap());
1220    }
1221
1222    #[test]
1223    fn referral_state_camel_case_keys() {
1224        let state = HlReferralState::new(None, None, Decimal::ZERO, Decimal::ZERO);
1225        let json = serde_json::to_string(&state).unwrap();
1226        assert!(json.contains("cumVlm"));
1227        assert!(json.contains("referralCode"));
1228    }
1229}