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 (one entry of the `delegations` query).
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    /// Timestamp (ms) until which the delegation is locked.
237    #[serde(default)]
238    pub locked_until_timestamp: u64,
239    /// Pending rewards, if reported (the `delegations` response does not include
240    /// this field; defaults to `0`).
241    #[serde(default)]
242    pub rewards: Decimal,
243}
244
245impl HlStakingDelegation {
246    /// Creates a new `HlStakingDelegation`.
247    pub fn new(
248        validator: String,
249        amount: Decimal,
250        locked_until_timestamp: u64,
251        rewards: Decimal,
252    ) -> Self {
253        Self {
254            validator,
255            amount,
256            locked_until_timestamp,
257            rewards,
258        }
259    }
260}
261
262/// A summary of an address's staking position (`delegatorSummary`).
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(rename_all = "camelCase")]
265#[non_exhaustive]
266pub struct HlStakingSummary {
267    /// Total amount currently delegated.
268    pub delegated: Decimal,
269    /// Total amount undelegated and available.
270    pub undelegated: Decimal,
271    /// Total amount pending withdrawal.
272    pub total_pending_withdrawal: Decimal,
273    /// Number of pending withdrawals.
274    pub n_pending_withdrawals: u64,
275}
276
277impl HlStakingSummary {
278    /// Creates a new `HlStakingSummary`.
279    pub fn new(
280        delegated: Decimal,
281        undelegated: Decimal,
282        total_pending_withdrawal: Decimal,
283        n_pending_withdrawals: u64,
284    ) -> Self {
285        Self {
286            delegated,
287            undelegated,
288            total_pending_withdrawal,
289            n_pending_withdrawals,
290        }
291    }
292}
293
294/// A single staking reward entry (`delegatorRewards`).
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(rename_all = "camelCase")]
297#[non_exhaustive]
298pub struct HlStakingReward {
299    /// Reward timestamp in milliseconds.
300    pub time: u64,
301    /// Reward source (e.g. `"delegation"`, `"commission"`).
302    pub source: String,
303    /// Total reward amount.
304    pub total_amount: Decimal,
305}
306
307impl HlStakingReward {
308    /// Creates a new `HlStakingReward`.
309    pub fn new(time: u64, source: String, total_amount: Decimal) -> Self {
310        Self {
311            time,
312            source,
313            total_amount,
314        }
315    }
316}
317
318/// Borrow/lend position.
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase")]
321#[non_exhaustive]
322pub struct HlBorrowLendState {
323    /// Token/coin name.
324    pub coin: String,
325    /// Amount supplied/lent.
326    pub supply: Decimal,
327    /// Amount borrowed.
328    pub borrow: Decimal,
329    /// Current APY rate.
330    pub apy: Decimal,
331}
332
333impl HlBorrowLendState {
334    /// Creates a new `HlBorrowLendState`.
335    pub fn new(coin: String, supply: Decimal, borrow: Decimal, apy: Decimal) -> Self {
336        Self {
337            coin,
338            supply,
339            borrow,
340            apy,
341        }
342    }
343}
344
345/// An open order on Hyperliquid.
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(rename_all = "camelCase")]
348#[non_exhaustive]
349pub struct HlOpenOrder {
350    /// Order ID.
351    pub oid: u64,
352    /// The coin/asset symbol.
353    pub coin: String,
354    /// Order side.
355    pub side: crate::market::TradeSide,
356    /// Limit price.
357    pub limit_px: Decimal,
358    /// Order size.
359    pub sz: Decimal,
360    /// Timestamp in milliseconds.
361    pub timestamp: u64,
362    /// Order type (e.g. "Limit").
363    pub order_type: String,
364    /// Client order ID, if set.
365    pub cloid: Option<String>,
366}
367
368impl HlOpenOrder {
369    /// Creates a new `HlOpenOrder`.
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        timestamp: u64,
378        order_type: String,
379        cloid: Option<String>,
380    ) -> Self {
381        Self {
382            oid,
383            coin,
384            side,
385            limit_px,
386            sz,
387            timestamp,
388            order_type,
389            cloid,
390        }
391    }
392}
393
394/// An open order with the richer "frontend" metadata (trigger conditions,
395/// TP/SL bracket info, original size, children) returned by the
396/// `frontendOpenOrders` query — a superset of [`HlOpenOrder`].
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(rename_all = "camelCase")]
399#[non_exhaustive]
400pub struct HlFrontendOpenOrder {
401    /// Order ID.
402    pub oid: u64,
403    /// The coin/asset symbol.
404    pub coin: String,
405    /// Order side.
406    pub side: crate::market::TradeSide,
407    /// Limit price.
408    pub limit_px: Decimal,
409    /// Current remaining size.
410    pub sz: Decimal,
411    /// Original size when the order was placed.
412    pub orig_sz: Decimal,
413    /// Timestamp in milliseconds.
414    pub timestamp: u64,
415    /// Order type label (e.g. "Limit", "Stop Market", "Take Profit Limit").
416    pub order_type: String,
417    /// Time-in-force, if applicable (may be absent for trigger orders).
418    pub tif: Option<String>,
419    /// Whether this order only reduces an existing position.
420    pub reduce_only: bool,
421    /// Whether this is a trigger (stop/take-profit) order.
422    pub is_trigger: bool,
423    /// Whether this order is a position-level TP/SL.
424    pub is_position_tpsl: bool,
425    /// Human-readable trigger condition (e.g. "N/A" for non-trigger orders).
426    pub trigger_condition: String,
427    /// Trigger price (`0` for non-trigger orders).
428    pub trigger_px: Decimal,
429    /// Client order ID, if set.
430    pub cloid: Option<String>,
431    /// Child orders (e.g. the TP/SL legs of a bracket); empty when none.
432    pub children: Vec<HlFrontendOpenOrder>,
433}
434
435impl HlFrontendOpenOrder {
436    /// Creates a new `HlFrontendOpenOrder`.
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        orig_sz: Decimal,
445        timestamp: u64,
446        order_type: String,
447        tif: Option<String>,
448        reduce_only: bool,
449        is_trigger: bool,
450        is_position_tpsl: bool,
451        trigger_condition: String,
452        trigger_px: Decimal,
453        cloid: Option<String>,
454        children: Vec<HlFrontendOpenOrder>,
455    ) -> Self {
456        Self {
457            oid,
458            coin,
459            side,
460            limit_px,
461            sz,
462            orig_sz,
463            timestamp,
464            order_type,
465            tif,
466            reduce_only,
467            is_trigger,
468            is_position_tpsl,
469            trigger_condition,
470            trigger_px,
471            cloid,
472            children,
473        }
474    }
475}
476
477/// Detailed status of a single order.
478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479#[serde(rename_all = "camelCase")]
480#[non_exhaustive]
481pub struct HlOrderDetail {
482    /// Order ID.
483    pub oid: u64,
484    /// The coin/asset symbol.
485    pub coin: String,
486    /// Order side.
487    pub side: crate::market::TradeSide,
488    /// Limit price.
489    pub limit_px: Decimal,
490    /// Order size.
491    pub sz: Decimal,
492    /// Timestamp in milliseconds.
493    pub timestamp: u64,
494    /// Order type (e.g. "Limit").
495    pub order_type: String,
496    /// Client order ID, if set.
497    pub cloid: Option<String>,
498    /// Order status (e.g. "open", "filled", "canceled").
499    pub status: String,
500}
501
502impl HlOrderDetail {
503    /// Creates a new `HlOrderDetail`.
504    #[allow(clippy::too_many_arguments)]
505    pub fn new(
506        oid: u64,
507        coin: String,
508        side: crate::market::TradeSide,
509        limit_px: Decimal,
510        sz: Decimal,
511        timestamp: u64,
512        order_type: String,
513        cloid: Option<String>,
514        status: String,
515    ) -> Self {
516        Self {
517            oid,
518            coin,
519            side,
520            limit_px,
521            sz,
522            timestamp,
523            order_type,
524            cloid,
525            status,
526        }
527    }
528}
529
530/// A funding rate entry for a coin.
531#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
532#[serde(rename_all = "camelCase")]
533#[non_exhaustive]
534pub struct HlFundingEntry {
535    /// The coin/asset symbol.
536    pub coin: String,
537    /// Funding rate.
538    pub funding_rate: Decimal,
539    /// Premium.
540    pub premium: Decimal,
541    /// Timestamp in milliseconds.
542    pub time: u64,
543}
544
545impl HlFundingEntry {
546    /// Creates a new `HlFundingEntry`.
547    pub fn new(coin: String, funding_rate: Decimal, premium: Decimal, time: u64) -> Self {
548        Self {
549            coin,
550            funding_rate,
551            premium,
552            time,
553        }
554    }
555}
556
557/// A user-specific funding entry.
558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
559#[serde(rename_all = "camelCase")]
560#[non_exhaustive]
561pub struct HlUserFundingEntry {
562    /// The coin/asset symbol.
563    pub coin: String,
564    /// USDC amount.
565    pub usdc: Decimal,
566    /// Size (signed).
567    pub szi: Decimal,
568    /// Funding rate.
569    pub funding_rate: Decimal,
570    /// Timestamp in milliseconds.
571    pub time: u64,
572}
573
574impl HlUserFundingEntry {
575    /// Creates a new `HlUserFundingEntry`.
576    pub fn new(
577        coin: String,
578        usdc: Decimal,
579        szi: Decimal,
580        funding_rate: Decimal,
581        time: u64,
582    ) -> Self {
583        Self {
584            coin,
585            usdc,
586            szi,
587            funding_rate,
588            time,
589        }
590    }
591}
592
593/// A historical order with status.
594#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
595#[serde(rename_all = "camelCase")]
596#[non_exhaustive]
597pub struct HlHistoricalOrder {
598    /// Order ID.
599    pub oid: u64,
600    /// The coin/asset symbol.
601    pub coin: String,
602    /// Order side.
603    pub side: crate::market::TradeSide,
604    /// Limit price.
605    pub limit_px: Decimal,
606    /// Order size.
607    pub sz: Decimal,
608    /// Timestamp in milliseconds.
609    pub timestamp: u64,
610    /// Order type (e.g. "Limit").
611    pub order_type: String,
612    /// Client order ID, if set.
613    pub cloid: Option<String>,
614    /// Order status (e.g. "filled", "canceled").
615    pub status: String,
616}
617
618impl HlHistoricalOrder {
619    /// Creates a new `HlHistoricalOrder`.
620    #[allow(clippy::too_many_arguments)]
621    pub fn new(
622        oid: u64,
623        coin: String,
624        side: crate::market::TradeSide,
625        limit_px: Decimal,
626        sz: Decimal,
627        timestamp: u64,
628        order_type: String,
629        cloid: Option<String>,
630        status: String,
631    ) -> Self {
632        Self {
633            oid,
634            coin,
635            side,
636            limit_px,
637            sz,
638            timestamp,
639            order_type,
640            cloid,
641            status,
642        }
643    }
644}
645
646/// Referral state for a Hyperliquid account.
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
648#[serde(rename_all = "camelCase")]
649#[non_exhaustive]
650pub struct HlReferralState {
651    /// The address of the referrer, if any.
652    pub referrer: Option<String>,
653    /// The user's own referral code, if any.
654    pub referral_code: Option<String>,
655    /// Cumulative volume traded.
656    pub cum_vlm: Decimal,
657    /// Referral rewards earned.
658    pub rewards: Decimal,
659}
660
661impl HlReferralState {
662    /// Creates a new `HlReferralState`.
663    pub fn new(
664        referrer: Option<String>,
665        referral_code: Option<String>,
666        cum_vlm: Decimal,
667        rewards: Decimal,
668    ) -> Self {
669        Self {
670            referrer,
671            referral_code,
672            cum_vlm,
673            rewards,
674        }
675    }
676}
677
678/// Active asset data for a user's position in a specific coin.
679#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
680#[serde(rename_all = "camelCase")]
681#[non_exhaustive]
682pub struct HlActiveAssetData {
683    /// The coin/asset symbol.
684    pub coin: String,
685    /// Current leverage for this asset.
686    pub leverage: Decimal,
687    /// Maximum trade sizes (buy/sell).
688    pub max_trade_szs: Vec<Decimal>,
689    /// Available to trade amounts (buy/sell).
690    pub available_to_trade: Vec<Decimal>,
691    /// Current mark price.
692    pub mark_px: Decimal,
693}
694
695impl HlActiveAssetData {
696    /// Creates a new `HlActiveAssetData`.
697    pub fn new(
698        coin: String,
699        leverage: Decimal,
700        max_trade_szs: Vec<Decimal>,
701        available_to_trade: Vec<Decimal>,
702        mark_px: Decimal,
703    ) -> Self {
704        Self {
705            coin,
706            leverage,
707            max_trade_szs,
708            available_to_trade,
709            mark_px,
710        }
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use crate::market::TradeSide;
718    use std::str::FromStr;
719
720    #[test]
721    fn position_serde_roundtrip() {
722        let pos = HlPosition {
723            coin: "BTC".into(),
724            size: Decimal::from_str("0.5").unwrap(),
725            entry_px: Decimal::from_str("60000.0").unwrap(),
726            unrealized_pnl: Decimal::from_str("150.0").unwrap(),
727            leverage: Decimal::from_str("10.0").unwrap(),
728            liquidation_px: Some(Decimal::from_str("54000.0").unwrap()),
729        };
730        let json = serde_json::to_string(&pos).unwrap();
731        let parsed: HlPosition = serde_json::from_str(&json).unwrap();
732        assert_eq!(parsed.coin, "BTC");
733        assert_eq!(parsed.size, Decimal::from_str("0.5").unwrap());
734        assert_eq!(parsed.entry_px, Decimal::from_str("60000.0").unwrap());
735        assert_eq!(parsed.unrealized_pnl, Decimal::from_str("150.0").unwrap());
736        assert_eq!(parsed.leverage, Decimal::from_str("10.0").unwrap());
737        assert_eq!(
738            parsed.liquidation_px,
739            Some(Decimal::from_str("54000.0").unwrap())
740        );
741    }
742
743    #[test]
744    fn position_no_liquidation_px_roundtrip() {
745        let pos = HlPosition {
746            coin: "ETH".into(),
747            size: Decimal::from_str("-2.0").unwrap(),
748            entry_px: Decimal::from_str("3000.0").unwrap(),
749            unrealized_pnl: Decimal::from_str("-50.0").unwrap(),
750            leverage: Decimal::from_str("5.0").unwrap(),
751            liquidation_px: None,
752        };
753        let json = serde_json::to_string(&pos).unwrap();
754        let parsed: HlPosition = serde_json::from_str(&json).unwrap();
755        assert!(parsed.liquidation_px.is_none());
756        assert!(parsed.size < Decimal::ZERO);
757    }
758
759    #[test]
760    fn position_camel_case_keys() {
761        let pos = HlPosition {
762            coin: "X".into(),
763            size: Decimal::ONE,
764            entry_px: Decimal::ONE,
765            unrealized_pnl: Decimal::ZERO,
766            leverage: Decimal::ONE,
767            liquidation_px: None,
768        };
769        let json = serde_json::to_string(&pos).unwrap();
770        assert!(json.contains("entryPx"));
771        assert!(json.contains("unrealizedPnl"));
772        assert!(json.contains("liquidationPx"));
773    }
774
775    #[test]
776    fn fill_serde_roundtrip() {
777        let fill = HlFill {
778            coin: "ETH".into(),
779            px: Decimal::from_str("3000.0").unwrap(),
780            sz: Decimal::from_str("1.5").unwrap(),
781            is_buy: true,
782            timestamp: 1700000000000,
783            fee: Decimal::from_str("0.75").unwrap(),
784            closed_pnl: Decimal::ZERO,
785        };
786        let json = serde_json::to_string(&fill).unwrap();
787        let parsed: HlFill = serde_json::from_str(&json).unwrap();
788        assert_eq!(parsed.coin, "ETH");
789        assert_eq!(parsed.px, Decimal::from_str("3000.0").unwrap());
790        assert_eq!(parsed.sz, Decimal::from_str("1.5").unwrap());
791        assert!(parsed.is_buy);
792        assert_eq!(parsed.timestamp, 1700000000000);
793        assert_eq!(parsed.fee, Decimal::from_str("0.75").unwrap());
794        assert_eq!(parsed.closed_pnl, Decimal::ZERO);
795    }
796
797    #[test]
798    fn fill_camel_case_keys() {
799        let fill = HlFill {
800            coin: "X".into(),
801            px: Decimal::ONE,
802            sz: Decimal::ONE,
803            is_buy: false,
804            timestamp: 0,
805            fee: Decimal::ZERO,
806            closed_pnl: Decimal::from_str("100.0").unwrap(),
807        };
808        let json = serde_json::to_string(&fill).unwrap();
809        assert!(json.contains("isBuy"));
810        assert!(json.contains("closedPnl"));
811    }
812
813    #[test]
814    fn account_state_serde_roundtrip() {
815        let state = HlAccountState {
816            equity: Decimal::from_str("100000.0").unwrap(),
817            margin_available: Decimal::from_str("50000.0").unwrap(),
818            positions: vec![HlPosition {
819                coin: "BTC".into(),
820                size: Decimal::from_str("0.1").unwrap(),
821                entry_px: Decimal::from_str("60000.0").unwrap(),
822                unrealized_pnl: Decimal::ZERO,
823                leverage: Decimal::from_str("10.0").unwrap(),
824                liquidation_px: None,
825            }],
826        };
827        let json = serde_json::to_string(&state).unwrap();
828        let parsed: HlAccountState = serde_json::from_str(&json).unwrap();
829        assert_eq!(parsed.equity, Decimal::from_str("100000.0").unwrap());
830        assert_eq!(
831            parsed.margin_available,
832            Decimal::from_str("50000.0").unwrap()
833        );
834        assert_eq!(parsed.positions.len(), 1);
835        assert_eq!(parsed.positions[0].coin, "BTC");
836    }
837
838    #[test]
839    fn account_state_empty_positions_roundtrip() {
840        let state = HlAccountState {
841            equity: Decimal::ZERO,
842            margin_available: Decimal::ZERO,
843            positions: vec![],
844        };
845        let json = serde_json::to_string(&state).unwrap();
846        let parsed: HlAccountState = serde_json::from_str(&json).unwrap();
847        assert!(parsed.positions.is_empty());
848    }
849
850    #[test]
851    fn account_state_camel_case_keys() {
852        let state = HlAccountState {
853            equity: Decimal::ONE,
854            margin_available: Decimal::ONE,
855            positions: vec![],
856        };
857        let json = serde_json::to_string(&state).unwrap();
858        assert!(json.contains("marginAvailable"));
859    }
860
861    #[test]
862    fn vault_summary_serde_roundtrip() {
863        let json = serde_json::json!({
864            "vaultAddress": "0xabc123",
865            "name": "My Vault",
866            "leaderEquity": "10000.0",
867            "followerEquity": "50000.0",
868            "allTimePnl": "2500.0"
869        });
870        let parsed: HlVaultSummary = serde_json::from_value(json).unwrap();
871        assert_eq!(parsed.vault_address, "0xabc123");
872        assert_eq!(parsed.name, "My Vault");
873        assert_eq!(
874            parsed.leader_equity,
875            Some(Decimal::from_str("10000.0").unwrap())
876        );
877        assert_eq!(
878            parsed.follower_equity,
879            Some(Decimal::from_str("50000.0").unwrap())
880        );
881        assert_eq!(
882            parsed.all_time_pnl,
883            Some(Decimal::from_str("2500.0").unwrap())
884        );
885    }
886
887    #[test]
888    fn vault_summary_minimal_fields() {
889        let json = serde_json::json!({
890            "vaultAddress": "0xdef456",
891            "name": "Minimal Vault"
892        });
893        let parsed: HlVaultSummary = serde_json::from_value(json).unwrap();
894        assert_eq!(parsed.vault_address, "0xdef456");
895        assert_eq!(parsed.name, "Minimal Vault");
896        assert!(parsed.leader_equity.is_none());
897        assert!(parsed.follower_equity.is_none());
898        assert!(parsed.all_time_pnl.is_none());
899    }
900
901    #[test]
902    fn vault_summary_extra_fields_captured() {
903        let json = serde_json::json!({
904            "vaultAddress": "0x111",
905            "name": "V",
906            "someNewField": 42
907        });
908        let parsed: HlVaultSummary = serde_json::from_value(json).unwrap();
909        assert_eq!(
910            parsed.extra.get("someNewField").unwrap(),
911            &serde_json::json!(42)
912        );
913    }
914
915    #[test]
916    fn vault_summary_camel_case_keys() {
917        let summary = HlVaultSummary {
918            vault_address: "0x1".into(),
919            name: "V".into(),
920            leader_equity: Some(Decimal::ONE),
921            follower_equity: None,
922            all_time_pnl: None,
923            extra: HashMap::new(),
924        };
925        let json = serde_json::to_string(&summary).unwrap();
926        assert!(json.contains("vaultAddress"));
927        assert!(json.contains("leaderEquity"));
928    }
929
930    #[test]
931    fn vault_details_serde_roundtrip() {
932        let json = serde_json::json!({
933            "name": "Alpha Vault",
934            "vaultAddress": "0xvault",
935            "leader": "0xleader",
936            "portfolio": {"equity": "100000"},
937            "followerCount": 25
938        });
939        let parsed: HlVaultDetails = serde_json::from_value(json).unwrap();
940        assert_eq!(parsed.name, "Alpha Vault");
941        assert_eq!(parsed.vault_address, "0xvault");
942        assert_eq!(parsed.leader.as_deref(), Some("0xleader"));
943        assert!(parsed.portfolio.is_some());
944        assert_eq!(parsed.follower_count, Some(25));
945    }
946
947    #[test]
948    fn vault_details_minimal_fields() {
949        let json = serde_json::json!({
950            "name": "Min",
951            "vaultAddress": "0xmin"
952        });
953        let parsed: HlVaultDetails = serde_json::from_value(json).unwrap();
954        assert_eq!(parsed.name, "Min");
955        assert!(parsed.leader.is_none());
956        assert!(parsed.portfolio.is_none());
957        assert!(parsed.follower_count.is_none());
958    }
959
960    #[test]
961    fn vault_details_extra_fields_captured() {
962        let json = serde_json::json!({
963            "name": "V",
964            "vaultAddress": "0x1",
965            "customMetric": "hello"
966        });
967        let parsed: HlVaultDetails = serde_json::from_value(json).unwrap();
968        assert_eq!(
969            parsed.extra.get("customMetric").unwrap(),
970            &serde_json::json!("hello")
971        );
972    }
973
974    #[test]
975    fn extra_agent_serde_roundtrip() {
976        let json = serde_json::json!({
977            "address": "0xagent1",
978            "name": "Trading Bot"
979        });
980        let parsed: HlExtraAgent = serde_json::from_value(json).unwrap();
981        assert_eq!(parsed.address, "0xagent1");
982        assert_eq!(parsed.name.as_deref(), Some("Trading Bot"));
983    }
984
985    #[test]
986    fn extra_agent_minimal_fields() {
987        let json = serde_json::json!({
988            "address": "0xagent2"
989        });
990        let parsed: HlExtraAgent = serde_json::from_value(json).unwrap();
991        assert_eq!(parsed.address, "0xagent2");
992        assert!(parsed.name.is_none());
993    }
994
995    #[test]
996    fn extra_agent_extra_fields_captured() {
997        let json = serde_json::json!({
998            "address": "0xagent3",
999            "permissions": ["trade", "withdraw"]
1000        });
1001        let parsed: HlExtraAgent = serde_json::from_value(json).unwrap();
1002        assert!(parsed.extra.contains_key("permissions"));
1003    }
1004
1005    #[test]
1006    fn staking_delegation_serde_roundtrip() {
1007        let delegation = HlStakingDelegation {
1008            validator: "0xval1".into(),
1009            amount: Decimal::from_str("1000.0").unwrap(),
1010            locked_until_timestamp: 1_700_000_000_000,
1011            rewards: Decimal::from_str("5.25").unwrap(),
1012        };
1013        let json = serde_json::to_string(&delegation).unwrap();
1014        let parsed: HlStakingDelegation = serde_json::from_str(&json).unwrap();
1015        assert_eq!(parsed.validator, "0xval1");
1016        assert_eq!(parsed.amount, Decimal::from_str("1000.0").unwrap());
1017        assert_eq!(parsed.locked_until_timestamp, 1_700_000_000_000);
1018        assert_eq!(parsed.rewards, Decimal::from_str("5.25").unwrap());
1019    }
1020
1021    #[test]
1022    fn staking_delegation_from_json() {
1023        let json = serde_json::json!({
1024            "validator": "0xabc",
1025            "amount": "500.0",
1026            "rewards": "2.5"
1027        });
1028        let parsed: HlStakingDelegation = serde_json::from_value(json).unwrap();
1029        assert_eq!(parsed.validator, "0xabc");
1030        assert_eq!(parsed.amount, Decimal::from_str("500.0").unwrap());
1031        assert_eq!(parsed.rewards, Decimal::from_str("2.5").unwrap());
1032    }
1033
1034    #[test]
1035    fn borrow_lend_state_serde_roundtrip() {
1036        let state = HlBorrowLendState {
1037            coin: "USDC".into(),
1038            supply: Decimal::from_str("10000.0").unwrap(),
1039            borrow: Decimal::from_str("5000.0").unwrap(),
1040            apy: Decimal::from_str("0.05").unwrap(),
1041        };
1042        let json = serde_json::to_string(&state).unwrap();
1043        let parsed: HlBorrowLendState = serde_json::from_str(&json).unwrap();
1044        assert_eq!(parsed.coin, "USDC");
1045        assert_eq!(parsed.supply, Decimal::from_str("10000.0").unwrap());
1046        assert_eq!(parsed.borrow, Decimal::from_str("5000.0").unwrap());
1047        assert_eq!(parsed.apy, Decimal::from_str("0.05").unwrap());
1048    }
1049
1050    #[test]
1051    fn borrow_lend_state_from_json() {
1052        let json = serde_json::json!({
1053            "coin": "ETH",
1054            "supply": "100.0",
1055            "borrow": "0.0",
1056            "apy": "0.03"
1057        });
1058        let parsed: HlBorrowLendState = serde_json::from_value(json).unwrap();
1059        assert_eq!(parsed.coin, "ETH");
1060        assert_eq!(parsed.supply, Decimal::from_str("100.0").unwrap());
1061        assert_eq!(parsed.borrow, Decimal::ZERO);
1062        assert_eq!(parsed.apy, Decimal::from_str("0.03").unwrap());
1063    }
1064
1065    #[test]
1066    fn borrow_lend_state_camel_case_keys() {
1067        let state = HlBorrowLendState {
1068            coin: "X".into(),
1069            supply: Decimal::ONE,
1070            borrow: Decimal::ZERO,
1071            apy: Decimal::ZERO,
1072        };
1073        let json = serde_json::to_string(&state).unwrap();
1074        // All fields are single-word or camelCase; just verify it serializes
1075        assert!(json.contains("supply"));
1076        assert!(json.contains("borrow"));
1077        assert!(json.contains("apy"));
1078    }
1079
1080    #[test]
1081    fn extra_agent_camel_case_keys() {
1082        let agent = HlExtraAgent {
1083            address: "0x1".into(),
1084            name: Some("Bot".into()),
1085            extra: HashMap::new(),
1086        };
1087        let json = serde_json::to_string(&agent).unwrap();
1088        assert!(json.contains("address"));
1089        assert!(json.contains("name"));
1090    }
1091
1092    #[test]
1093    fn user_fees_serde_roundtrip() {
1094        let fees = HlUserFees {
1095            fee_tier: "VIP2".into(),
1096            maker_rate: Decimal::from_str("0.0001").unwrap(),
1097            taker_rate: Decimal::from_str("0.0003").unwrap(),
1098        };
1099        let json = serde_json::to_string(&fees).unwrap();
1100        let parsed: HlUserFees = serde_json::from_str(&json).unwrap();
1101        assert_eq!(parsed.fee_tier, "VIP2");
1102        assert_eq!(parsed.maker_rate, Decimal::from_str("0.0001").unwrap());
1103        assert_eq!(parsed.taker_rate, Decimal::from_str("0.0003").unwrap());
1104    }
1105
1106    #[test]
1107    fn user_fees_camel_case_keys() {
1108        let fees = HlUserFees {
1109            fee_tier: "T1".into(),
1110            maker_rate: Decimal::ZERO,
1111            taker_rate: Decimal::ONE,
1112        };
1113        let json = serde_json::to_string(&fees).unwrap();
1114        assert!(json.contains("feeTier"));
1115        assert!(json.contains("makerRate"));
1116        assert!(json.contains("takerRate"));
1117    }
1118
1119    #[test]
1120    fn user_fees_constructor() {
1121        let fees = HlUserFees::new(
1122            "VIP1".into(),
1123            Decimal::from_str("0.0002").unwrap(),
1124            Decimal::from_str("0.0005").unwrap(),
1125        );
1126        assert_eq!(fees.fee_tier, "VIP1");
1127        assert_eq!(fees.maker_rate, Decimal::from_str("0.0002").unwrap());
1128        assert_eq!(fees.taker_rate, Decimal::from_str("0.0005").unwrap());
1129    }
1130
1131    #[test]
1132    fn rate_limit_status_serde_roundtrip() {
1133        let status = HlRateLimitStatus {
1134            used: 42,
1135            limit: 1200,
1136            window_ms: 60000,
1137        };
1138        let json = serde_json::to_string(&status).unwrap();
1139        let parsed: HlRateLimitStatus = serde_json::from_str(&json).unwrap();
1140        assert_eq!(parsed.used, 42);
1141        assert_eq!(parsed.limit, 1200);
1142        assert_eq!(parsed.window_ms, 60000);
1143    }
1144
1145    #[test]
1146    fn rate_limit_status_camel_case_keys() {
1147        let status = HlRateLimitStatus {
1148            used: 0,
1149            limit: 100,
1150            window_ms: 30000,
1151        };
1152        let json = serde_json::to_string(&status).unwrap();
1153        assert!(json.contains("windowMs"));
1154    }
1155
1156    #[test]
1157    fn rate_limit_status_constructor() {
1158        let status = HlRateLimitStatus::new(10, 500, 60000);
1159        assert_eq!(status.used, 10);
1160        assert_eq!(status.limit, 500);
1161        assert_eq!(status.window_ms, 60000);
1162    }
1163
1164    #[test]
1165    fn open_order_serde_roundtrip() {
1166        let order = HlOpenOrder {
1167            oid: 12345,
1168            coin: "BTC".into(),
1169            side: TradeSide::Buy,
1170            limit_px: Decimal::from_str("60000.0").unwrap(),
1171            sz: Decimal::from_str("0.5").unwrap(),
1172            timestamp: 1700000000000,
1173            order_type: "Limit".into(),
1174            cloid: Some("my-order-1".into()),
1175        };
1176        let json = serde_json::to_string(&order).unwrap();
1177        let parsed: HlOpenOrder = serde_json::from_str(&json).unwrap();
1178        assert_eq!(parsed.oid, 12345);
1179        assert_eq!(parsed.coin, "BTC");
1180        assert_eq!(parsed.side, TradeSide::Buy);
1181        assert_eq!(parsed.limit_px, Decimal::from_str("60000.0").unwrap());
1182        assert_eq!(parsed.sz, Decimal::from_str("0.5").unwrap());
1183        assert_eq!(parsed.timestamp, 1700000000000);
1184        assert_eq!(parsed.order_type, "Limit");
1185        assert_eq!(parsed.cloid.as_deref(), Some("my-order-1"));
1186    }
1187
1188    #[test]
1189    fn open_order_no_cloid_roundtrip() {
1190        let order = HlOpenOrder {
1191            oid: 99,
1192            coin: "ETH".into(),
1193            side: TradeSide::Sell,
1194            limit_px: Decimal::from_str("3000.0").unwrap(),
1195            sz: Decimal::ONE,
1196            timestamp: 0,
1197            order_type: "Limit".into(),
1198            cloid: None,
1199        };
1200        let json = serde_json::to_string(&order).unwrap();
1201        let parsed: HlOpenOrder = serde_json::from_str(&json).unwrap();
1202        assert!(parsed.cloid.is_none());
1203    }
1204
1205    #[test]
1206    fn open_order_camel_case_keys() {
1207        let order = HlOpenOrder {
1208            oid: 1,
1209            coin: "X".into(),
1210            side: TradeSide::Buy,
1211            limit_px: Decimal::ONE,
1212            sz: Decimal::ONE,
1213            timestamp: 0,
1214            order_type: "Limit".into(),
1215            cloid: None,
1216        };
1217        let json = serde_json::to_string(&order).unwrap();
1218        assert!(json.contains("limitPx"));
1219        assert!(json.contains("orderType"));
1220    }
1221
1222    #[test]
1223    fn order_detail_serde_roundtrip() {
1224        let detail = HlOrderDetail {
1225            oid: 555,
1226            coin: "SOL".into(),
1227            side: TradeSide::Buy,
1228            limit_px: Decimal::from_str("150.0").unwrap(),
1229            sz: Decimal::from_str("10.0").unwrap(),
1230            timestamp: 1700000000000,
1231            order_type: "Limit".into(),
1232            cloid: None,
1233            status: "filled".into(),
1234        };
1235        let json = serde_json::to_string(&detail).unwrap();
1236        let parsed: HlOrderDetail = serde_json::from_str(&json).unwrap();
1237        assert_eq!(parsed.oid, 555);
1238        assert_eq!(parsed.status, "filled");
1239        assert_eq!(parsed.coin, "SOL");
1240    }
1241
1242    #[test]
1243    fn order_detail_camel_case_keys() {
1244        let detail = HlOrderDetail {
1245            oid: 1,
1246            coin: "X".into(),
1247            side: TradeSide::Buy,
1248            limit_px: Decimal::ONE,
1249            sz: Decimal::ONE,
1250            timestamp: 0,
1251            order_type: "Limit".into(),
1252            cloid: Some("c1".into()),
1253            status: "open".into(),
1254        };
1255        let json = serde_json::to_string(&detail).unwrap();
1256        assert!(json.contains("limitPx"));
1257        assert!(json.contains("orderType"));
1258    }
1259
1260    #[test]
1261    fn funding_entry_serde_roundtrip() {
1262        let entry = HlFundingEntry {
1263            coin: "BTC".into(),
1264            funding_rate: Decimal::from_str("0.0001").unwrap(),
1265            premium: Decimal::from_str("0.00005").unwrap(),
1266            time: 1700000000000,
1267        };
1268        let json = serde_json::to_string(&entry).unwrap();
1269        let parsed: HlFundingEntry = serde_json::from_str(&json).unwrap();
1270        assert_eq!(parsed.coin, "BTC");
1271        assert_eq!(parsed.funding_rate, Decimal::from_str("0.0001").unwrap());
1272        assert_eq!(parsed.premium, Decimal::from_str("0.00005").unwrap());
1273        assert_eq!(parsed.time, 1700000000000);
1274    }
1275
1276    #[test]
1277    fn funding_entry_camel_case_keys() {
1278        let entry = HlFundingEntry {
1279            coin: "X".into(),
1280            funding_rate: Decimal::ONE,
1281            premium: Decimal::ZERO,
1282            time: 0,
1283        };
1284        let json = serde_json::to_string(&entry).unwrap();
1285        assert!(json.contains("fundingRate"));
1286    }
1287
1288    #[test]
1289    fn user_funding_entry_serde_roundtrip() {
1290        let entry = HlUserFundingEntry {
1291            coin: "ETH".into(),
1292            usdc: Decimal::from_str("-1.5").unwrap(),
1293            szi: Decimal::from_str("2.0").unwrap(),
1294            funding_rate: Decimal::from_str("0.0002").unwrap(),
1295            time: 1700000000000,
1296        };
1297        let json = serde_json::to_string(&entry).unwrap();
1298        let parsed: HlUserFundingEntry = serde_json::from_str(&json).unwrap();
1299        assert_eq!(parsed.coin, "ETH");
1300        assert_eq!(parsed.usdc, Decimal::from_str("-1.5").unwrap());
1301        assert_eq!(parsed.szi, Decimal::from_str("2.0").unwrap());
1302        assert_eq!(parsed.funding_rate, Decimal::from_str("0.0002").unwrap());
1303        assert_eq!(parsed.time, 1700000000000);
1304    }
1305
1306    #[test]
1307    fn user_funding_entry_camel_case_keys() {
1308        let entry = HlUserFundingEntry {
1309            coin: "X".into(),
1310            usdc: Decimal::ONE,
1311            szi: Decimal::ONE,
1312            funding_rate: Decimal::ZERO,
1313            time: 0,
1314        };
1315        let json = serde_json::to_string(&entry).unwrap();
1316        assert!(json.contains("fundingRate"));
1317    }
1318
1319    #[test]
1320    fn historical_order_serde_roundtrip() {
1321        let order = HlHistoricalOrder {
1322            oid: 777,
1323            coin: "BTC".into(),
1324            side: TradeSide::Sell,
1325            limit_px: Decimal::from_str("65000.0").unwrap(),
1326            sz: Decimal::from_str("0.1").unwrap(),
1327            timestamp: 1700000000000,
1328            order_type: "Limit".into(),
1329            cloid: Some("hist-1".into()),
1330            status: "filled".into(),
1331        };
1332        let json = serde_json::to_string(&order).unwrap();
1333        let parsed: HlHistoricalOrder = serde_json::from_str(&json).unwrap();
1334        assert_eq!(parsed.oid, 777);
1335        assert_eq!(parsed.status, "filled");
1336        assert_eq!(parsed.coin, "BTC");
1337        assert_eq!(parsed.cloid.as_deref(), Some("hist-1"));
1338    }
1339
1340    #[test]
1341    fn historical_order_camel_case_keys() {
1342        let order = HlHistoricalOrder {
1343            oid: 1,
1344            coin: "X".into(),
1345            side: TradeSide::Buy,
1346            limit_px: Decimal::ONE,
1347            sz: Decimal::ONE,
1348            timestamp: 0,
1349            order_type: "Limit".into(),
1350            cloid: None,
1351            status: "canceled".into(),
1352        };
1353        let json = serde_json::to_string(&order).unwrap();
1354        assert!(json.contains("limitPx"));
1355        assert!(json.contains("orderType"));
1356    }
1357
1358    #[test]
1359    fn referral_state_serde_roundtrip() {
1360        let state = HlReferralState::new(
1361            Some("0xabc".into()),
1362            Some("CODE123".into()),
1363            Decimal::from_str("50000.0").unwrap(),
1364            Decimal::from_str("100.5").unwrap(),
1365        );
1366        let json = serde_json::to_string(&state).unwrap();
1367        let parsed: HlReferralState = serde_json::from_str(&json).unwrap();
1368        assert_eq!(parsed.referrer.as_deref(), Some("0xabc"));
1369        assert_eq!(parsed.referral_code.as_deref(), Some("CODE123"));
1370        assert_eq!(parsed.cum_vlm, Decimal::from_str("50000.0").unwrap());
1371        assert_eq!(parsed.rewards, Decimal::from_str("100.5").unwrap());
1372    }
1373
1374    #[test]
1375    fn referral_state_camel_case_keys() {
1376        let state = HlReferralState::new(None, None, Decimal::ZERO, Decimal::ZERO);
1377        let json = serde_json::to_string(&state).unwrap();
1378        assert!(json.contains("cumVlm"));
1379        assert!(json.contains("referralCode"));
1380    }
1381}