Skip to main content

nautilus_hyperliquid/http/
query.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use serde::{Serialize, ser::SerializeStruct};
17
18use crate::{
19    common::{
20        enums::{HyperliquidBarInterval, HyperliquidInfoRequestType},
21        parse::is_false_or_none,
22    },
23    http::models::{
24        HyperliquidExchangeBuilderFee, HyperliquidExchangeCancelByCloidRequest,
25        HyperliquidExchangeGrouping, HyperliquidExchangeModifyOrderRequest,
26        HyperliquidExchangePlaceOrderRequest,
27    },
28};
29
30/// Exchange action types for Hyperliquid.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub enum ExchangeActionType {
34    /// Place orders
35    Order,
36    /// Cancel orders by order ID
37    Cancel,
38    /// Cancel orders by client order ID
39    CancelByCloid,
40    /// Modify an existing order
41    Modify,
42    /// Update leverage for an asset
43    UpdateLeverage,
44    /// Update isolated margin for an asset
45    UpdateIsolatedMargin,
46}
47
48impl AsRef<str> for ExchangeActionType {
49    fn as_ref(&self) -> &str {
50        match self {
51            Self::Order => "order",
52            Self::Cancel => "cancel",
53            Self::CancelByCloid => "cancelByCloid",
54            Self::Modify => "modify",
55            Self::UpdateLeverage => "updateLeverage",
56            Self::UpdateIsolatedMargin => "updateIsolatedMargin",
57        }
58    }
59}
60
61/// Parameters for placing orders.
62#[derive(Debug, Clone, Serialize)]
63pub struct OrderParams {
64    pub orders: Vec<HyperliquidExchangePlaceOrderRequest>,
65    pub grouping: HyperliquidExchangeGrouping,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub builder: Option<HyperliquidExchangeBuilderFee>,
68}
69
70/// Parameters for canceling orders.
71///
72/// The fast-cancel flag is serialized only when true.
73#[derive(Debug, Clone, Serialize)]
74pub struct CancelParams {
75    pub cancels: Vec<HyperliquidExchangeCancelByCloidRequest>,
76    #[serde(rename = "f", skip_serializing_if = "is_false_or_none")]
77    pub fast: Option<bool>,
78}
79
80/// Parameters for modifying an order.
81#[derive(Debug, Clone, Serialize)]
82pub struct ModifyParams {
83    #[serde(flatten)]
84    pub request: HyperliquidExchangeModifyOrderRequest,
85}
86
87/// Parameters for updating leverage.
88#[derive(Debug, Clone, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct UpdateLeverageParams {
91    pub asset: u32,
92    pub is_cross: bool,
93    pub leverage: u32,
94}
95
96/// Parameters for updating isolated margin.
97#[derive(Debug, Clone, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct UpdateIsolatedMarginParams {
100    pub asset: u32,
101    pub is_buy: bool,
102    pub ntli: i64,
103}
104
105/// Parameters for L2 book request.
106#[derive(Debug, Clone, Serialize)]
107pub struct L2BookParams {
108    pub coin: String,
109}
110
111/// Parameters for recent trades request.
112#[derive(Debug, Clone, Serialize)]
113pub struct RecentTradesParams {
114    pub coin: String,
115}
116
117/// Parameters for user fills request.
118#[derive(Debug, Clone)]
119pub struct UserFillsParams {
120    pub user: String,
121}
122
123impl Serialize for UserFillsParams {
124    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
125    where
126        S: serde::Serializer,
127    {
128        let mut state = serializer.serialize_struct("UserFillsParams", 2)?;
129        state.serialize_field("user", &self.user)?;
130        state.serialize_field("aggregateByTime", &false)?;
131        state.end()
132    }
133}
134
135/// Parameters for order status request.
136#[derive(Debug, Clone, Serialize)]
137pub struct OrderStatusParams {
138    pub user: String,
139    pub oid: u64,
140}
141
142/// Parameters for open orders request.
143#[derive(Debug, Clone, Serialize)]
144pub struct OpenOrdersParams {
145    pub user: String,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub dex: Option<String>,
148}
149
150/// Parameters for clearinghouse state request.
151#[derive(Debug, Clone, Serialize)]
152pub struct ClearinghouseStateParams {
153    pub user: String,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub dex: Option<String>,
156}
157
158/// Parameters for spot clearinghouse state request.
159#[derive(Debug, Clone, Serialize)]
160pub struct SpotClearinghouseStateParams {
161    pub user: String,
162}
163
164/// Parameters for candle snapshot request.
165#[derive(Debug, Clone, Serialize)]
166#[serde(rename_all = "camelCase")]
167pub struct CandleSnapshotReq {
168    pub coin: String,
169    pub interval: HyperliquidBarInterval,
170    pub start_time: u64,
171    pub end_time: u64,
172}
173
174/// Wrapper for candle snapshot parameters.
175#[derive(Debug, Clone, Serialize)]
176pub struct CandleSnapshotParams {
177    pub req: CandleSnapshotReq,
178}
179
180/// Parameters for funding history request.
181#[derive(Debug, Clone, Serialize)]
182#[serde(rename_all = "camelCase")]
183pub struct FundingHistoryParams {
184    pub coin: String,
185    pub start_time: u64,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub end_time: Option<u64>,
188}
189
190/// Info request parameters.
191#[derive(Debug, Clone, Serialize)]
192#[serde(untagged)]
193pub enum InfoRequestParams {
194    L2Book(L2BookParams),
195    RecentTrades(RecentTradesParams),
196    UserFills(UserFillsParams),
197    OrderStatus(OrderStatusParams),
198    OpenOrders(OpenOrdersParams),
199    ClearinghouseState(ClearinghouseStateParams),
200    SpotClearinghouseState(SpotClearinghouseStateParams),
201    CandleSnapshot(CandleSnapshotParams),
202    FundingHistory(FundingHistoryParams),
203    None,
204}
205
206/// Represents an info request wrapper for `POST /info`.
207#[derive(Debug, Clone, Serialize)]
208pub struct InfoRequest {
209    #[serde(rename = "type")]
210    pub request_type: HyperliquidInfoRequestType,
211    #[serde(flatten)]
212    pub params: InfoRequestParams,
213}
214
215impl InfoRequest {
216    /// Creates a request to get metadata about available markets.
217    pub fn meta() -> Self {
218        Self {
219            request_type: HyperliquidInfoRequestType::Meta,
220            params: InfoRequestParams::None,
221        }
222    }
223
224    /// Creates a request to get metadata for all perp dexes (standard + HIP-3).
225    pub fn all_perp_metas() -> Self {
226        Self {
227            request_type: HyperliquidInfoRequestType::AllPerpMetas,
228            params: InfoRequestParams::None,
229        }
230    }
231
232    /// Creates a request to get the list of perp dexes.
233    pub fn perp_dexs() -> Self {
234        Self {
235            request_type: HyperliquidInfoRequestType::PerpDexs,
236            params: InfoRequestParams::None,
237        }
238    }
239
240    /// Creates a request to get spot metadata (tokens and pairs).
241    pub fn spot_meta() -> Self {
242        Self {
243            request_type: HyperliquidInfoRequestType::SpotMeta,
244            params: InfoRequestParams::None,
245        }
246    }
247
248    /// Creates a request to get metadata with asset contexts (for price precision).
249    pub fn meta_and_asset_ctxs() -> Self {
250        Self {
251            request_type: HyperliquidInfoRequestType::MetaAndAssetCtxs,
252            params: InfoRequestParams::None,
253        }
254    }
255
256    /// Creates a request to get spot metadata with asset contexts.
257    pub fn spot_meta_and_asset_ctxs() -> Self {
258        Self {
259            request_type: HyperliquidInfoRequestType::SpotMetaAndAssetCtxs,
260            params: InfoRequestParams::None,
261        }
262    }
263
264    /// Creates a request to get outcome metadata.
265    pub fn outcome_meta() -> Self {
266        Self {
267            request_type: HyperliquidInfoRequestType::OutcomeMeta,
268            params: InfoRequestParams::None,
269        }
270    }
271
272    /// Creates a request to get L2 order book for a coin.
273    pub fn l2_book(coin: &str) -> Self {
274        Self {
275            request_type: HyperliquidInfoRequestType::L2Book,
276            params: InfoRequestParams::L2Book(L2BookParams {
277                coin: coin.to_string(),
278            }),
279        }
280    }
281
282    /// Creates a request to get recent public trades for a coin.
283    pub fn recent_trades(coin: &str) -> Self {
284        Self {
285            request_type: HyperliquidInfoRequestType::RecentTrades,
286            params: InfoRequestParams::RecentTrades(RecentTradesParams {
287                coin: coin.to_string(),
288            }),
289        }
290    }
291
292    /// Creates a request to get user fills.
293    pub fn user_fills(user: &str) -> Self {
294        Self {
295            request_type: HyperliquidInfoRequestType::UserFills,
296            params: InfoRequestParams::UserFills(UserFillsParams {
297                user: user.to_string(),
298            }),
299        }
300    }
301
302    /// Creates a request to get order status for a user.
303    pub fn order_status(user: &str, oid: u64) -> Self {
304        Self {
305            request_type: HyperliquidInfoRequestType::OrderStatus,
306            params: InfoRequestParams::OrderStatus(OrderStatusParams {
307                user: user.to_string(),
308                oid,
309            }),
310        }
311    }
312
313    /// Creates a request to get all open orders for a user.
314    pub fn open_orders(user: &str) -> Self {
315        Self::open_orders_for_dex(user, None)
316    }
317
318    /// Creates a request to get all open orders for a user on a specific perp dex.
319    pub(crate) fn open_orders_for_dex(user: &str, dex: Option<&str>) -> Self {
320        Self {
321            request_type: HyperliquidInfoRequestType::OpenOrders,
322            params: InfoRequestParams::OpenOrders(OpenOrdersParams {
323                user: user.to_string(),
324                dex: dex.map(str::to_string),
325            }),
326        }
327    }
328
329    /// Creates a request to get frontend open orders (includes more detail).
330    pub fn frontend_open_orders(user: &str) -> Self {
331        Self::frontend_open_orders_for_dex(user, None)
332    }
333
334    /// Creates a frontend open-orders request for a user on a specific perp dex.
335    pub(crate) fn frontend_open_orders_for_dex(user: &str, dex: Option<&str>) -> Self {
336        Self {
337            request_type: HyperliquidInfoRequestType::FrontendOpenOrders,
338            params: InfoRequestParams::OpenOrders(OpenOrdersParams {
339                user: user.to_string(),
340                dex: dex.map(str::to_string),
341            }),
342        }
343    }
344
345    /// Creates a request to get historical orders for a user.
346    pub fn historical_orders(user: &str) -> Self {
347        Self {
348            request_type: HyperliquidInfoRequestType::HistoricalOrders,
349            params: InfoRequestParams::OpenOrders(OpenOrdersParams {
350                user: user.to_string(),
351                dex: None,
352            }),
353        }
354    }
355
356    /// Creates a request to get user state (balances, positions, margin).
357    pub fn clearinghouse_state(user: &str) -> Self {
358        Self::clearinghouse_state_for_dex(user, None)
359    }
360
361    /// Creates a clearinghouse-state request for a user on a specific perp dex.
362    pub(crate) fn clearinghouse_state_for_dex(user: &str, dex: Option<&str>) -> Self {
363        Self {
364            request_type: HyperliquidInfoRequestType::ClearinghouseState,
365            params: InfoRequestParams::ClearinghouseState(ClearinghouseStateParams {
366                user: user.to_string(),
367                dex: dex.map(str::to_string),
368            }),
369        }
370    }
371
372    /// Creates a request to get spot clearinghouse state (per-token spot balances).
373    pub fn spot_clearinghouse_state(user: &str) -> Self {
374        Self {
375            request_type: HyperliquidInfoRequestType::SpotClearinghouseState,
376            params: InfoRequestParams::SpotClearinghouseState(SpotClearinghouseStateParams {
377                user: user.to_string(),
378            }),
379        }
380    }
381
382    /// Creates a request to get the account abstraction mode for a user.
383    pub fn user_abstraction(user: &str) -> Self {
384        Self {
385            request_type: HyperliquidInfoRequestType::UserAbstraction,
386            params: InfoRequestParams::SpotClearinghouseState(SpotClearinghouseStateParams {
387                user: user.to_string(),
388            }),
389        }
390    }
391
392    /// Creates a request to get user fee schedule and effective rates.
393    pub fn user_fees(user: &str) -> Self {
394        Self {
395            request_type: HyperliquidInfoRequestType::UserFees,
396            params: InfoRequestParams::OpenOrders(OpenOrdersParams {
397                user: user.to_string(),
398                dex: None,
399            }),
400        }
401    }
402
403    /// Creates a request to get candle/bar data.
404    pub fn candle_snapshot(
405        coin: &str,
406        interval: HyperliquidBarInterval,
407        start_time: u64,
408        end_time: u64,
409    ) -> Self {
410        Self {
411            request_type: HyperliquidInfoRequestType::CandleSnapshot,
412            params: InfoRequestParams::CandleSnapshot(CandleSnapshotParams {
413                req: CandleSnapshotReq {
414                    coin: coin.to_string(),
415                    interval,
416                    start_time,
417                    end_time,
418                },
419            }),
420        }
421    }
422
423    /// Creates a request to get funding rate history for a coin.
424    pub fn funding_history(coin: &str, start_time: u64, end_time: Option<u64>) -> Self {
425        Self {
426            request_type: HyperliquidInfoRequestType::FundingHistory,
427            params: InfoRequestParams::FundingHistory(FundingHistoryParams {
428                coin: coin.to_string(),
429                start_time,
430                end_time,
431            }),
432        }
433    }
434}
435
436/// Exchange action parameters.
437#[derive(Debug, Clone, Serialize)]
438#[serde(untagged)]
439pub enum ExchangeActionParams {
440    Order(OrderParams),
441    Cancel(CancelParams),
442    Modify(ModifyParams),
443    UpdateLeverage(UpdateLeverageParams),
444    UpdateIsolatedMargin(UpdateIsolatedMarginParams),
445}
446
447/// Represents an exchange action wrapper for `POST /exchange`.
448#[derive(Debug, Clone, Serialize)]
449pub struct ExchangeAction {
450    #[serde(rename = "type", serialize_with = "serialize_action_type")]
451    pub action_type: ExchangeActionType,
452    #[serde(flatten)]
453    pub params: ExchangeActionParams,
454}
455
456fn serialize_action_type<S>(
457    action_type: &ExchangeActionType,
458    serializer: S,
459) -> Result<S::Ok, S::Error>
460where
461    S: serde::Serializer,
462{
463    serializer.serialize_str(action_type.as_ref())
464}
465
466impl ExchangeAction {
467    /// Creates an action to place orders with builder attribution.
468    pub fn order(
469        orders: Vec<HyperliquidExchangePlaceOrderRequest>,
470        builder: Option<HyperliquidExchangeBuilderFee>,
471    ) -> Self {
472        Self {
473            action_type: ExchangeActionType::Order,
474            params: ExchangeActionParams::Order(OrderParams {
475                orders,
476                grouping: HyperliquidExchangeGrouping::Na,
477                builder,
478            }),
479        }
480    }
481
482    /// Creates an action to cancel orders.
483    pub fn cancel(cancels: Vec<HyperliquidExchangeCancelByCloidRequest>) -> Self {
484        Self {
485            action_type: ExchangeActionType::Cancel,
486            params: ExchangeActionParams::Cancel(CancelParams {
487                cancels,
488                fast: None,
489            }),
490        }
491    }
492
493    /// Creates an action to cancel orders by client order ID.
494    pub fn cancel_by_cloid(cancels: Vec<HyperliquidExchangeCancelByCloidRequest>) -> Self {
495        Self {
496            action_type: ExchangeActionType::CancelByCloid,
497            params: ExchangeActionParams::Cancel(CancelParams {
498                cancels,
499                fast: None,
500            }),
501        }
502    }
503
504    /// Creates an action to modify an order.
505    pub fn modify(request: HyperliquidExchangeModifyOrderRequest) -> Self {
506        Self {
507            action_type: ExchangeActionType::Modify,
508            params: ExchangeActionParams::Modify(ModifyParams { request }),
509        }
510    }
511
512    /// Creates an action to update leverage for an asset.
513    pub fn update_leverage(asset: u32, is_cross: bool, leverage: u32) -> Self {
514        Self {
515            action_type: ExchangeActionType::UpdateLeverage,
516            params: ExchangeActionParams::UpdateLeverage(UpdateLeverageParams {
517                asset,
518                is_cross,
519                leverage,
520            }),
521        }
522    }
523
524    /// Creates an action to update isolated margin for an asset.
525    pub fn update_isolated_margin(asset: u32, is_buy: bool, ntli: i64) -> Self {
526        Self {
527            action_type: ExchangeActionType::UpdateIsolatedMargin,
528            params: ExchangeActionParams::UpdateIsolatedMargin(UpdateIsolatedMarginParams {
529                asset,
530                is_buy,
531                ntli,
532            }),
533        }
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use rstest::rstest;
540    use rust_decimal::Decimal;
541
542    use super::*;
543    use crate::http::models::{
544        Cloid, HyperliquidExchangeCancelByCloidRequest, HyperliquidExchangeLimitParams,
545        HyperliquidExchangeModifyOrderRequest, HyperliquidExchangeOrderKind,
546        HyperliquidExchangePlaceOrderRequest, HyperliquidExchangeTif,
547    };
548
549    #[rstest]
550    fn test_cancel_action_serialization(
551        #[values(None, Some(false), Some(true))] fast: Option<bool>,
552        #[values(false, true)] by_cloid: bool,
553    ) {
554        let cancels = vec![HyperliquidExchangeCancelByCloidRequest {
555            asset: 7,
556            cloid: Cloid::from_hex("0x0123456789abcdef0123456789abcdef").unwrap(),
557        }];
558
559        let (mut action, name) = if by_cloid {
560            (ExchangeAction::cancel_by_cloid(cancels), "cancel_by_cloid")
561        } else {
562            (ExchangeAction::cancel(cancels), "query_cancel")
563        };
564
565        let ExchangeActionParams::Cancel(params) = &mut action.params else {
566            panic!("Expected cancel parameters");
567        };
568
569        params.fast = fast;
570        let fixtures: serde_json::Value =
571            serde_json::from_str(include_str!("../../test_data/cancel_actions.json")).unwrap();
572        let expected = &fixtures[name][if fast == Some(true) { "fast" } else { "normal" }];
573        let json = serde_json::to_value(&action).unwrap();
574        let msgpack = rmp_serde::to_vec_named(&action).unwrap();
575
576        assert_eq!(json, expected["json"]);
577        assert_eq!(
578            nautilus_core::hex::encode(msgpack),
579            expected["msgpack"].as_str().unwrap()
580        );
581    }
582
583    #[rstest]
584    fn test_info_request_meta() {
585        let req = InfoRequest::meta();
586
587        assert_eq!(req.request_type, HyperliquidInfoRequestType::Meta);
588        assert!(matches!(req.params, InfoRequestParams::None));
589    }
590
591    #[rstest]
592    fn test_info_request_all_perp_metas() {
593        let req = InfoRequest::all_perp_metas();
594
595        assert_eq!(req.request_type, HyperliquidInfoRequestType::AllPerpMetas);
596        let json = serde_json::to_string(&req).unwrap();
597        assert!(json.contains(r#""type":"allPerpMetas""#));
598    }
599
600    #[rstest]
601    fn test_info_request_outcome_meta() {
602        let req = InfoRequest::outcome_meta();
603
604        assert_eq!(req.request_type, HyperliquidInfoRequestType::OutcomeMeta);
605        assert!(matches!(req.params, InfoRequestParams::None));
606        let json = serde_json::to_string(&req).unwrap();
607        assert_eq!(json, r#"{"type":"outcomeMeta"}"#);
608    }
609
610    #[rstest]
611    fn test_info_request_l2_book() {
612        let req = InfoRequest::l2_book("BTC");
613
614        assert_eq!(req.request_type, HyperliquidInfoRequestType::L2Book);
615        let json = serde_json::to_string(&req).unwrap();
616        assert!(json.contains("\"coin\":\"BTC\""));
617    }
618
619    #[rstest]
620    fn test_info_request_recent_trades() {
621        let req = InfoRequest::recent_trades("BTC");
622
623        assert_eq!(req.request_type, HyperliquidInfoRequestType::RecentTrades);
624        let json = serde_json::to_string(&req).unwrap();
625        assert_eq!(json, r#"{"type":"recentTrades","coin":"BTC"}"#);
626    }
627
628    #[rstest]
629    fn test_info_request_open_orders_dex_serialization() {
630        let default = serde_json::to_value(InfoRequest::open_orders("0xabc")).unwrap();
631        let xyz =
632            serde_json::to_value(InfoRequest::open_orders_for_dex("0xabc", Some("xyz"))).unwrap();
633
634        assert_eq!(
635            default,
636            serde_json::json!({"type": "openOrders", "user": "0xabc"})
637        );
638        assert_eq!(
639            xyz,
640            serde_json::json!({"type": "openOrders", "user": "0xabc", "dex": "xyz"})
641        );
642    }
643
644    #[rstest]
645    fn test_info_request_frontend_open_orders_dex_serialization() {
646        let default = serde_json::to_value(InfoRequest::frontend_open_orders("0xabc")).unwrap();
647        let xyz = serde_json::to_value(InfoRequest::frontend_open_orders_for_dex(
648            "0xabc",
649            Some("xyz"),
650        ))
651        .unwrap();
652
653        assert_eq!(
654            default,
655            serde_json::json!({"type": "frontendOpenOrders", "user": "0xabc"})
656        );
657        assert_eq!(
658            xyz,
659            serde_json::json!({"type": "frontendOpenOrders", "user": "0xabc", "dex": "xyz"})
660        );
661    }
662
663    #[rstest]
664    fn test_info_request_clearinghouse_state_dex_serialization() {
665        let default = serde_json::to_value(InfoRequest::clearinghouse_state("0xabc")).unwrap();
666        let xyz = serde_json::to_value(InfoRequest::clearinghouse_state_for_dex(
667            "0xabc",
668            Some("xyz"),
669        ))
670        .unwrap();
671
672        assert_eq!(
673            default,
674            serde_json::json!({"type": "clearinghouseState", "user": "0xabc"})
675        );
676        assert_eq!(
677            xyz,
678            serde_json::json!({"type": "clearinghouseState", "user": "0xabc", "dex": "xyz"})
679        );
680    }
681
682    #[rstest]
683    fn test_info_request_spot_clearinghouse_state() {
684        let req = InfoRequest::spot_clearinghouse_state("0xabc");
685
686        assert_eq!(
687            req.request_type,
688            HyperliquidInfoRequestType::SpotClearinghouseState
689        );
690        let json = serde_json::to_string(&req).unwrap();
691        assert!(json.contains(r#""type":"spotClearinghouseState""#));
692        assert!(json.contains(r#""user":"0xabc""#));
693    }
694
695    #[rstest]
696    fn test_info_request_user_abstraction() {
697        let req = InfoRequest::user_abstraction("0xabc");
698
699        assert_eq!(
700            serde_json::to_value(&req).unwrap(),
701            serde_json::json!({"type": "userAbstraction", "user": "0xabc"})
702        );
703    }
704
705    #[rstest]
706    fn test_info_request_funding_history_with_end_time() {
707        let req = InfoRequest::funding_history("BTC", 1_700_000_000_000, Some(1_700_003_600_000));
708
709        assert_eq!(req.request_type, HyperliquidInfoRequestType::FundingHistory);
710        let json = serde_json::to_string(&req).unwrap();
711        assert!(json.contains(r#""type":"fundingHistory""#));
712        assert!(json.contains(r#""coin":"BTC""#));
713        assert!(json.contains(r#""startTime":1700000000000"#));
714        assert!(json.contains(r#""endTime":1700003600000"#));
715    }
716
717    #[rstest]
718    fn test_info_request_funding_history_omits_end_time_when_none() {
719        // Hyperliquid defaults `endTime` to current time when absent; the
720        // serializer must omit the field rather than emit `null`.
721        let req = InfoRequest::funding_history("BTC", 1_700_000_000_000, None);
722        let json = serde_json::to_string(&req).unwrap();
723        assert!(json.contains(r#""startTime":1700000000000"#));
724        assert!(
725            !json.contains("endTime"),
726            "endTime must be omitted when None; json={json}",
727        );
728    }
729
730    #[rstest]
731    fn test_exchange_action_order() {
732        let order = HyperliquidExchangePlaceOrderRequest {
733            asset: 0,
734            is_buy: true,
735            price: Decimal::new(50000, 0),
736            size: Decimal::new(1, 0),
737            reduce_only: false,
738            kind: HyperliquidExchangeOrderKind::Limit {
739                limit: HyperliquidExchangeLimitParams {
740                    tif: HyperliquidExchangeTif::Gtc,
741                },
742            },
743            cloid: None,
744        };
745
746        let action = ExchangeAction::order(vec![order], None);
747
748        assert_eq!(action.action_type, ExchangeActionType::Order);
749        let json = serde_json::to_string(&action).unwrap();
750        assert!(json.contains("\"orders\""));
751    }
752
753    #[rstest]
754    fn test_exchange_action_cancel() {
755        let cancel = HyperliquidExchangeCancelByCloidRequest {
756            asset: 0,
757            cloid: Cloid::from_hex("0x00000000000000000000000000000000").unwrap(),
758        };
759
760        let action = ExchangeAction::cancel(vec![cancel]);
761
762        assert_eq!(action.action_type, ExchangeActionType::Cancel);
763    }
764
765    #[rstest]
766    fn test_exchange_action_serialization() {
767        let order = HyperliquidExchangePlaceOrderRequest {
768            asset: 0,
769            is_buy: true,
770            price: Decimal::new(50000, 0),
771            size: Decimal::new(1, 0),
772            reduce_only: false,
773            kind: HyperliquidExchangeOrderKind::Limit {
774                limit: HyperliquidExchangeLimitParams {
775                    tif: HyperliquidExchangeTif::Gtc,
776                },
777            },
778            cloid: None,
779        };
780
781        let action = ExchangeAction::order(vec![order], None);
782
783        let json = serde_json::to_string(&action).unwrap();
784        // Verify that action_type is serialized as "type" with the correct string value
785        assert!(json.contains(r#""type":"order""#));
786        assert!(json.contains(r#""orders""#));
787        assert!(json.contains(r#""grouping":"na""#));
788    }
789
790    #[rstest]
791    fn test_exchange_action_type_as_ref() {
792        assert_eq!(ExchangeActionType::Order.as_ref(), "order");
793        assert_eq!(ExchangeActionType::Cancel.as_ref(), "cancel");
794        assert_eq!(ExchangeActionType::CancelByCloid.as_ref(), "cancelByCloid");
795        assert_eq!(ExchangeActionType::Modify.as_ref(), "modify");
796        assert_eq!(
797            ExchangeActionType::UpdateLeverage.as_ref(),
798            "updateLeverage"
799        );
800        assert_eq!(
801            ExchangeActionType::UpdateIsolatedMargin.as_ref(),
802            "updateIsolatedMargin"
803        );
804    }
805
806    #[rstest]
807    fn test_update_leverage_serialization() {
808        let action = ExchangeAction::update_leverage(1, true, 10);
809        let json = serde_json::to_string(&action).unwrap();
810
811        assert!(json.contains(r#""type":"updateLeverage""#));
812        assert!(json.contains(r#""asset":1"#));
813        assert!(json.contains(r#""isCross":true"#));
814        assert!(json.contains(r#""leverage":10"#));
815    }
816
817    #[rstest]
818    fn test_update_isolated_margin_serialization() {
819        let action = ExchangeAction::update_isolated_margin(2, false, 1000);
820        let json = serde_json::to_string(&action).unwrap();
821
822        assert!(json.contains(r#""type":"updateIsolatedMargin""#));
823        assert!(json.contains(r#""asset":2"#));
824        assert!(json.contains(r#""isBuy":false"#));
825        assert!(json.contains(r#""ntli":1000"#));
826    }
827
828    #[rstest]
829    fn test_cancel_by_cloid_serialization() {
830        let cancel_request = HyperliquidExchangeCancelByCloidRequest {
831            asset: 0,
832            cloid: Cloid::from_hex("0x00000000000000000000000000000000").unwrap(),
833        };
834        let action = ExchangeAction::cancel_by_cloid(vec![cancel_request]);
835        let json = serde_json::to_string(&action).unwrap();
836
837        assert!(json.contains(r#""type":"cancelByCloid""#));
838        assert!(json.contains(r#""cancels""#));
839    }
840
841    #[rstest]
842    fn test_modify_serialization() {
843        let modify_request = HyperliquidExchangeModifyOrderRequest {
844            oid: 12345.into(),
845            order: HyperliquidExchangePlaceOrderRequest {
846                asset: 0,
847                is_buy: true,
848                price: Decimal::new(51000, 0),
849                size: Decimal::new(2, 0),
850                reduce_only: false,
851                kind: HyperliquidExchangeOrderKind::Limit {
852                    limit: HyperliquidExchangeLimitParams {
853                        tif: HyperliquidExchangeTif::Gtc,
854                    },
855                },
856                cloid: None,
857            },
858        };
859        let action = ExchangeAction::modify(modify_request);
860        let json = serde_json::to_string(&action).unwrap();
861
862        assert!(json.contains(r#""type":"modify""#));
863        assert!(json.contains(r#""oid":12345"#));
864        assert!(json.contains(r#""order""#));
865    }
866}