1use std::{
17 sync::{
18 Arc,
19 atomic::{AtomicU64, Ordering},
20 },
21 time::Duration,
22};
23
24use ahash::AHashMap;
25use derive_builder::Builder;
26use futures_util::future::BoxFuture;
27use nautilus_common::live::get_runtime;
28use tokio::{
29 sync::{Mutex, OwnedSemaphorePermit, Semaphore, mpsc, oneshot},
30 time,
31};
32
33use crate::{
34 common::{consts::INFLIGHT_MAX, enums::HyperliquidInfoRequestType},
35 http::{
36 error::{Error, Result},
37 models::{HyperliquidFills, HyperliquidL2Book, HyperliquidOrderStatus},
38 },
39 websocket::messages::{
40 ActionRequest, CancelByCloidRequest, CancelRequest, HyperliquidWsRequest, ModifyRequest,
41 OrderRequest, OrderTypeRequest, PostRequest, PostResponse, TimeInForceRequest, TpSlRequest,
42 },
43};
44
45#[derive(Debug)]
46struct Waiter {
47 tx: oneshot::Sender<PostResponse>,
48 _permit: OwnedSemaphorePermit,
50}
51
52#[derive(Debug)]
53pub struct PostRouter {
54 inner: Mutex<AHashMap<u64, Waiter>>,
55 inflight: Arc<Semaphore>, }
57
58impl Default for PostRouter {
59 fn default() -> Self {
60 Self {
61 inner: Mutex::new(AHashMap::new()),
62 inflight: Arc::new(Semaphore::new(INFLIGHT_MAX)),
63 }
64 }
65}
66
67impl PostRouter {
68 pub fn new() -> Arc<Self> {
69 Arc::new(Self::default())
70 }
71
72 pub async fn register(&self, id: u64) -> Result<oneshot::Receiver<PostResponse>> {
74 let permit = self
76 .inflight
77 .clone()
78 .acquire_owned()
79 .await
80 .map_err(|_| Error::transport("post router semaphore closed"))?;
81
82 let (tx, rx) = oneshot::channel::<PostResponse>();
83 let mut map = self.inner.lock().await;
84 if map.contains_key(&id) {
85 return Err(Error::transport(format!("post id {id} already registered")));
86 }
87 map.insert(
88 id,
89 Waiter {
90 tx,
91 _permit: permit,
92 },
93 );
94 Ok(rx)
95 }
96
97 pub async fn complete(&self, resp: PostResponse) {
99 let id = resp.id;
100 let waiter = {
101 let mut map = self.inner.lock().await;
102 map.remove(&id)
103 };
104
105 if let Some(waiter) = waiter {
106 if waiter.tx.send(resp).is_err() {
107 log::warn!("Post waiter dropped before delivery: id={id}");
108 }
109 } else {
111 log::warn!("Post response with unknown id (late/duplicate?): id={id}");
112 }
113 }
114
115 pub async fn cancel(&self, id: u64) {
117 let _ = {
118 let mut map = self.inner.lock().await;
119 map.remove(&id)
120 };
121 }
123
124 pub async fn await_with_timeout(
126 &self,
127 id: u64,
128 rx: oneshot::Receiver<PostResponse>,
129 timeout: Duration,
130 ) -> Result<PostResponse> {
131 match time::timeout(timeout, rx).await {
132 Ok(Ok(resp)) => Ok(resp),
133 Ok(Err(_closed)) => {
134 self.cancel(id).await;
135 Err(Error::transport("post response channel closed"))
136 }
137 Err(_elapsed) => {
138 self.cancel(id).await;
139 Err(Error::Timeout)
140 }
141 }
142 }
143}
144
145#[derive(Debug)]
146pub struct PostIds(AtomicU64);
147
148impl PostIds {
149 pub fn new(start: u64) -> Self {
150 Self(AtomicU64::new(start))
151 }
152 pub fn next(&self) -> u64 {
153 self.0.fetch_add(1, Ordering::Relaxed)
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum PostLane {
159 Alo, Normal, }
162
163#[derive(Debug)]
164pub struct ScheduledPost {
165 pub id: u64,
166 pub request: PostRequest,
167 pub lane: PostLane,
168}
169
170#[derive(Debug)]
171pub struct PostBatcher {
172 tx_alo: mpsc::Sender<ScheduledPost>,
173 tx_normal: mpsc::Sender<ScheduledPost>,
174}
175
176impl PostBatcher {
177 pub fn new<F>(send_fn: F) -> Self
179 where
180 F: Send + 'static + Clone + FnMut(HyperliquidWsRequest) -> BoxFuture<'static, Result<()>>,
181 {
182 let (tx_alo, rx_alo) = mpsc::channel::<ScheduledPost>(1024);
183 let (tx_normal, rx_normal) = mpsc::channel::<ScheduledPost>(4096);
184
185 get_runtime().spawn(Self::run_lane(
187 "ALO",
188 rx_alo,
189 Duration::from_millis(100),
190 send_fn.clone(),
191 ));
192
193 get_runtime().spawn(Self::run_lane(
195 "NORMAL",
196 rx_normal,
197 Duration::from_millis(50),
198 send_fn,
199 ));
200
201 Self { tx_alo, tx_normal }
202 }
203
204 async fn run_lane<F>(
205 lane_name: &'static str,
206 mut rx: mpsc::Receiver<ScheduledPost>,
207 tick: Duration,
208 mut send_fn: F,
209 ) where
210 F: Send + 'static + FnMut(HyperliquidWsRequest) -> BoxFuture<'static, Result<()>>,
211 {
212 let mut pend: Vec<ScheduledPost> = Vec::with_capacity(128);
213 let mut interval = time::interval(tick);
214 interval.set_missed_tick_behavior(time::MissedTickBehavior::Delay);
215
216 loop {
217 tokio::select! {
218 maybe_item = rx.recv() => {
219 match maybe_item {
220 Some(item) => pend.push(item),
221 None => break, }
223 }
224 _ = interval.tick() => {
225 if pend.is_empty() { continue; }
226 let to_send = std::mem::take(&mut pend);
227 for item in to_send {
228 let req = HyperliquidWsRequest::Post { id: item.id, request: item.request.clone() };
229 if let Err(e) = send_fn(req).await {
230 log::error!("Failed to send post: lane={lane_name}, id={}, {e}", item.id);
231 }
232 }
233 }
234 }
235 }
236 log::debug!("Post lane terminated: lane={lane_name}");
237 }
238
239 pub async fn enqueue(&self, item: ScheduledPost) -> Result<()> {
240 match item.lane {
241 PostLane::Alo => self
242 .tx_alo
243 .send(item)
244 .await
245 .map_err(|_| Error::transport("ALO lane closed")),
246 PostLane::Normal => self
247 .tx_normal
248 .send(item)
249 .await
250 .map_err(|_| Error::transport("NORMAL lane closed")),
251 }
252 }
253}
254
255pub fn lane_for_action(action: &ActionRequest) -> PostLane {
257 match action {
258 ActionRequest::Order { orders, .. } => {
259 if orders.is_empty() {
260 return PostLane::Normal;
261 }
262 let all_alo = orders.iter().all(|o| {
263 matches!(
264 o.t,
265 OrderTypeRequest::Limit {
266 tif: TimeInForceRequest::Alo
267 }
268 )
269 });
270
271 if all_alo {
272 PostLane::Alo
273 } else {
274 PostLane::Normal
275 }
276 }
277 _ => PostLane::Normal,
278 }
279}
280
281#[derive(Debug, Clone, Copy, Default)]
282pub enum Grouping {
283 #[default]
284 Na,
285 NormalTpsl,
286 PositionTpsl,
287}
288impl Grouping {
289 pub fn as_str(&self) -> &'static str {
290 match self {
291 Self::Na => "na",
292 Self::NormalTpsl => "normalTpsl",
293 Self::PositionTpsl => "positionTpsl",
294 }
295 }
296}
297
298#[derive(Debug, Clone, Builder)]
300pub struct LimitOrderParams {
301 pub asset: u32,
302 pub is_buy: bool,
303 pub px: String,
304 pub sz: String,
305 pub reduce_only: bool,
306 pub tif: TimeInForceRequest,
307 pub cloid: Option<String>,
308}
309
310#[derive(Debug, Clone, Builder)]
312pub struct TriggerOrderParams {
313 pub asset: u32,
314 pub is_buy: bool,
315 pub px: String,
316 pub sz: String,
317 pub reduce_only: bool,
318 pub is_market: bool,
319 pub trigger_px: String,
320 pub tpsl: TpSlRequest,
321 pub cloid: Option<String>,
322}
323
324#[derive(Debug, Default)]
326pub struct OrderBuilder {
327 orders: Vec<OrderRequest>,
328 grouping: Grouping,
329}
330
331impl OrderBuilder {
332 pub fn new() -> Self {
333 Self::default()
334 }
335
336 #[must_use]
337 pub fn grouping(mut self, g: Grouping) -> Self {
338 self.grouping = g;
339 self
340 }
341
342 #[expect(clippy::too_many_arguments)]
344 #[must_use]
345 pub fn push_limit(
346 self,
347 asset: u32,
348 is_buy: bool,
349 px: &(impl ToString + ?Sized),
350 sz: &(impl ToString + ?Sized),
351 reduce_only: bool,
352 tif: TimeInForceRequest,
353 cloid: Option<String>,
354 ) -> Self {
355 let params = LimitOrderParams {
356 asset,
357 is_buy,
358 px: px.to_string(),
359 sz: sz.to_string(),
360 reduce_only,
361 tif,
362 cloid,
363 };
364 self.push_limit_order(params)
365 }
366
367 #[must_use]
369 pub fn push_limit_order(mut self, params: LimitOrderParams) -> Self {
370 self.orders.push(OrderRequest {
371 a: params.asset,
372 b: params.is_buy,
373 p: params.px,
374 s: params.sz,
375 r: params.reduce_only,
376 t: OrderTypeRequest::Limit { tif: params.tif },
377 c: params.cloid,
378 });
379 self
380 }
381
382 #[expect(clippy::too_many_arguments)]
384 #[must_use]
385 pub fn push_trigger(
386 self,
387 asset: u32,
388 is_buy: bool,
389 px: &(impl ToString + ?Sized),
390 sz: &(impl ToString + ?Sized),
391 reduce_only: bool,
392 is_market: bool,
393 trigger_px: &(impl ToString + ?Sized),
394 tpsl: TpSlRequest,
395 cloid: Option<String>,
396 ) -> Self {
397 let params = TriggerOrderParams {
398 asset,
399 is_buy,
400 px: px.to_string(),
401 sz: sz.to_string(),
402 reduce_only,
403 is_market,
404 trigger_px: trigger_px.to_string(),
405 tpsl,
406 cloid,
407 };
408 self.push_trigger_order(params)
409 }
410
411 #[must_use]
413 pub fn push_trigger_order(mut self, params: TriggerOrderParams) -> Self {
414 self.orders.push(OrderRequest {
415 a: params.asset,
416 b: params.is_buy,
417 p: params.px,
418 s: params.sz,
419 r: params.reduce_only,
420 t: OrderTypeRequest::Trigger {
421 is_market: params.is_market,
422 trigger_px: params.trigger_px,
423 tpsl: params.tpsl,
424 },
425 c: params.cloid,
426 });
427 self
428 }
429 pub fn build(self) -> ActionRequest {
430 ActionRequest::Order {
431 orders: self.orders,
432 grouping: self.grouping.as_str().to_string(),
433 }
434 }
435
436 pub fn single_limit_order(params: LimitOrderParams) -> ActionRequest {
453 Self::new().push_limit_order(params).build()
454 }
455
456 pub fn single_trigger_order(params: TriggerOrderParams) -> ActionRequest {
475 Self::new().push_trigger_order(params).build()
476 }
477}
478
479pub fn cancel_many(cancels: Vec<(u32, u64)>) -> ActionRequest {
480 ActionRequest::Cancel {
481 cancels: cancels
482 .into_iter()
483 .map(|(a, o)| CancelRequest { a, o })
484 .collect(),
485 fast: None,
486 }
487}
488pub fn cancel_by_cloid(asset: u32, cloid: impl Into<String>) -> ActionRequest {
489 ActionRequest::CancelByCloid {
490 cancels: vec![CancelByCloidRequest {
491 asset,
492 cloid: cloid.into(),
493 }],
494 fast: None,
495 }
496}
497pub fn modify(oid: u64, new_order: OrderRequest) -> ActionRequest {
498 ActionRequest::Modify {
499 modifies: vec![ModifyRequest {
500 oid,
501 order: new_order,
502 }],
503 }
504}
505
506pub fn info_l2_book(coin: &str) -> PostRequest {
507 PostRequest::Info {
508 payload: serde_json::json!({"type": HyperliquidInfoRequestType::L2Book.as_str(), "coin": coin}),
509 }
510}
511
512pub fn info_all_mids() -> PostRequest {
513 PostRequest::Info {
514 payload: serde_json::json!({"type": HyperliquidInfoRequestType::AllMids.as_str()}),
515 }
516}
517
518pub fn info_order_status(user: &str, oid: u64) -> PostRequest {
519 PostRequest::Info {
520 payload: serde_json::json!({"type": HyperliquidInfoRequestType::OrderStatus.as_str(), "user": user, "oid": oid}),
521 }
522}
523
524pub fn info_open_orders(user: &str, frontend: Option<bool>) -> PostRequest {
525 let mut body =
526 serde_json::json!({"type": HyperliquidInfoRequestType::OpenOrders.as_str(), "user": user});
527
528 if let Some(fe) = frontend {
529 body["frontend"] = serde_json::json!(fe);
530 }
531 PostRequest::Info { payload: body }
532}
533
534pub fn info_user_fills(user: &str, aggregate_by_time: Option<bool>) -> PostRequest {
535 let mut body =
536 serde_json::json!({"type": HyperliquidInfoRequestType::UserFills.as_str(), "user": user});
537
538 if let Some(agg) = aggregate_by_time {
539 body["aggregateByTime"] = serde_json::json!(agg);
540 }
541 PostRequest::Info { payload: body }
542}
543
544pub fn info_user_rate_limit(user: &str) -> PostRequest {
545 PostRequest::Info {
546 payload: serde_json::json!({"type": HyperliquidInfoRequestType::UserRateLimit.as_str(), "user": user}),
547 }
548}
549
550pub fn info_candle(coin: &str, interval: &str) -> PostRequest {
551 PostRequest::Info {
552 payload: serde_json::json!({"type": HyperliquidInfoRequestType::Candle.as_str(), "coin": coin, "interval": interval}),
553 }
554}
555
556pub fn parse_l2_book(payload: &serde_json::Value) -> Result<HyperliquidL2Book> {
557 serde_json::from_value(payload.clone()).map_err(Error::Serde)
558}
559pub fn parse_user_fills(payload: &serde_json::Value) -> Result<HyperliquidFills> {
560 serde_json::from_value(payload.clone()).map_err(Error::Serde)
561}
562pub fn parse_order_status(payload: &serde_json::Value) -> Result<HyperliquidOrderStatus> {
563 serde_json::from_value(payload.clone()).map_err(Error::Serde)
564}
565
566#[derive(Debug)]
568pub enum ActionOutcome<'a> {
569 Resting {
570 oid: u64,
571 },
572 Filled {
573 total_sz: &'a str,
574 avg_px: &'a str,
575 oid: Option<u64>,
576 },
577 Error {
578 msg: &'a str,
579 },
580 Unknown(&'a serde_json::Value),
581}
582pub fn classify_action_payload(payload: &serde_json::Value) -> ActionOutcome<'_> {
583 if let Some(oid) = payload.get("oid").and_then(|v| v.as_u64()) {
584 if let (Some(total_sz), Some(avg_px)) = (
585 payload.get("totalSz").and_then(|v| v.as_str()),
586 payload.get("avgPx").and_then(|v| v.as_str()),
587 ) {
588 return ActionOutcome::Filled {
589 total_sz,
590 avg_px,
591 oid: Some(oid),
592 };
593 }
594 return ActionOutcome::Resting { oid };
595 }
596
597 if let (Some(total_sz), Some(avg_px)) = (
598 payload.get("totalSz").and_then(|v| v.as_str()),
599 payload.get("avgPx").and_then(|v| v.as_str()),
600 ) {
601 return ActionOutcome::Filled {
602 total_sz,
603 avg_px,
604 oid: None,
605 };
606 }
607
608 if let Some(msg) = payload
609 .get("error")
610 .and_then(|v| v.as_str())
611 .or_else(|| payload.get("message").and_then(|v| v.as_str()))
612 {
613 return ActionOutcome::Error { msg };
614 }
615 ActionOutcome::Unknown(payload)
616}
617
618#[derive(Clone, Debug)]
619pub struct WsSender {
620 inner: mpsc::Sender<HyperliquidWsRequest>,
621}
622
623impl WsSender {
624 pub fn new(tx: mpsc::Sender<HyperliquidWsRequest>) -> Self {
625 Self { inner: tx }
626 }
627
628 pub async fn send(&self, req: HyperliquidWsRequest) -> Result<()> {
629 self.inner
630 .send(req)
631 .await
632 .map_err(|_| Error::transport("WebSocket sender closed"))
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use nautilus_common::testing::wait_until_async;
639 use rstest::rstest;
640 use tokio::{
641 sync::oneshot,
642 time::{Duration, timeout},
643 };
644
645 use super::*;
646 use crate::{
647 common::consts::INFLIGHT_MAX,
648 websocket::messages::{
649 ActionRequest, CancelByCloidRequest, CancelRequest, HyperliquidWsRequest, OrderRequest,
650 OrderRequestBuilder, OrderTypeRequest, TimeInForceRequest,
651 },
652 };
653
654 fn mk_limit_alo(asset: u32) -> OrderRequest {
655 OrderRequest {
656 a: asset,
657 b: true,
658 p: "1".to_string(),
659 s: "1".to_string(),
660 r: false,
661 t: OrderTypeRequest::Limit {
662 tif: TimeInForceRequest::Alo,
663 },
664 c: None,
665 }
666 }
667
668 fn mk_limit_gtc(asset: u32) -> OrderRequest {
669 OrderRequest {
670 a: asset,
671 b: true,
672 p: "1".to_string(),
673 s: "1".to_string(),
674 r: false,
675 t: OrderTypeRequest::Limit {
676 tif: TimeInForceRequest::Gtc,
678 },
679 c: None,
680 }
681 }
682
683 #[rstest]
684 #[tokio::test]
685 async fn test_ws_sender_forwards_and_reports_closed_channel() {
686 let (tx, mut rx) = mpsc::channel(1);
687 let sender = WsSender::new(tx);
688
689 sender.send(HyperliquidWsRequest::Ping).await.unwrap();
690 assert!(matches!(rx.recv().await, Some(HyperliquidWsRequest::Ping)));
691
692 drop(rx);
693 let error = sender.send(HyperliquidWsRequest::Ping).await.unwrap_err();
694 assert_eq!(
695 error.to_string(),
696 "transport error: WebSocket sender closed"
697 );
698 }
699
700 #[rstest]
701 #[tokio::test(flavor = "multi_thread")]
702 async fn register_duplicate_id_errors() {
703 let router = PostRouter::new();
704 let _rx = router.register(42).await.expect("first register OK");
705
706 let err = router.register(42).await.expect_err("duplicate must error");
707 let msg = err.to_string().to_lowercase();
708 assert!(
709 msg.contains("already") || msg.contains("duplicate"),
710 "unexpected error: {msg}"
711 );
712 }
713
714 #[rstest]
715 #[tokio::test(flavor = "multi_thread")]
716 async fn timeout_cancels_and_allows_reregister() {
717 let router = PostRouter::new();
718 let id = 7;
719
720 let rx = router.register(id).await.unwrap();
721 let err = router
723 .await_with_timeout(id, rx, Duration::from_millis(25))
724 .await
725 .expect_err("should timeout");
726 assert!(
727 err.to_string().to_lowercase().contains("timeout")
728 || err.to_string().to_lowercase().contains("closed"),
729 "unexpected error kind: {err}"
730 );
731
732 let _rx2 = router
734 .register(id)
735 .await
736 .expect("id should be reusable after timeout cancel");
737 }
738
739 #[rstest]
740 #[tokio::test(flavor = "multi_thread")]
741 async fn inflight_cap_blocks_then_unblocks() {
742 let router = PostRouter::new();
743
744 let mut rxs = Vec::with_capacity(INFLIGHT_MAX);
746 for i in 0..INFLIGHT_MAX {
747 let rx = router.register(i as u64).await.unwrap();
748 rxs.push(rx); }
750
751 let router2 = Arc::clone(&router);
753 let (entered_tx, entered_rx) = oneshot::channel::<()>();
754 let (done_tx, done_rx) = oneshot::channel::<()>();
755 let (check_tx, check_rx) = oneshot::channel::<()>(); get_runtime().spawn(async move {
758 let _ = entered_tx.send(());
759 let _rx = router2.register(9_999_999).await.unwrap();
760 let _ = done_tx.send(());
761 });
762
763 entered_rx.await.unwrap();
765
766 get_runtime().spawn(async move {
768 if done_rx.await.is_ok() {
769 let _ = check_tx.send(());
770 }
771 });
772
773 assert!(
774 timeout(Duration::from_millis(50), check_rx).await.is_err(),
775 "should still be blocked while at cap"
776 );
777
778 router.cancel(0).await;
780
781 tokio::time::sleep(Duration::from_millis(100)).await;
783 }
784
785 #[rstest(
786 orders, expected,
787 case::all_alo(vec![mk_limit_alo(0), mk_limit_alo(1)], PostLane::Alo),
788 case::mixed_alo_gtc(vec![mk_limit_alo(0), mk_limit_gtc(1)], PostLane::Normal),
789 case::all_gtc(vec![mk_limit_gtc(0), mk_limit_gtc(1)], PostLane::Normal),
790 case::empty(vec![], PostLane::Normal),
791 )]
792 fn lane_classifier_cases(orders: Vec<OrderRequest>, expected: PostLane) {
793 let action = ActionRequest::Order {
794 orders,
795 grouping: "na".to_string(),
796 };
797 assert_eq!(lane_for_action(&action), expected);
798 }
799
800 #[rstest]
801 fn test_order_request_builder() {
802 let order = OrderRequestBuilder::default()
804 .a(0)
805 .b(true)
806 .p("40000.0".to_string())
807 .s("0.01".to_string())
808 .r(false)
809 .t(OrderTypeRequest::Limit {
810 tif: TimeInForceRequest::Gtc,
811 })
812 .c(Some("test-order-1".to_string()))
813 .build()
814 .expect("should build order");
815
816 assert_eq!(order.a, 0);
817 assert!(order.b);
818 assert_eq!(order.p, "40000.0");
819 assert_eq!(order.s, "0.01");
820 assert!(!order.r);
821 assert_eq!(order.c, Some("test-order-1".to_string()));
822 }
823
824 #[rstest]
825 fn test_limit_order_params_builder() {
826 let params = LimitOrderParamsBuilder::default()
828 .asset(0)
829 .is_buy(true)
830 .px("40000.0".to_string())
831 .sz("0.01".to_string())
832 .reduce_only(false)
833 .tif(TimeInForceRequest::Alo)
834 .cloid(Some("test-limit-1".to_string()))
835 .build()
836 .expect("should build limit params");
837
838 assert_eq!(params.asset, 0);
839 assert!(params.is_buy);
840 assert_eq!(params.px, "40000.0");
841 assert_eq!(params.sz, "0.01");
842 assert!(!params.reduce_only);
843 assert_eq!(params.cloid, Some("test-limit-1".to_string()));
844 }
845
846 #[rstest]
847 fn test_trigger_order_params_builder() {
848 let params = TriggerOrderParamsBuilder::default()
850 .asset(1)
851 .is_buy(false)
852 .px("39000.0".to_string())
853 .sz("0.02".to_string())
854 .reduce_only(false)
855 .is_market(true)
856 .trigger_px("39500.0".to_string())
857 .tpsl(TpSlRequest::Sl)
858 .cloid(Some("test-trigger-1".to_string()))
859 .build()
860 .expect("should build trigger params");
861
862 assert_eq!(params.asset, 1);
863 assert!(!params.is_buy);
864 assert_eq!(params.px, "39000.0");
865 assert!(params.is_market);
866 assert_eq!(params.trigger_px, "39500.0");
867 }
868
869 #[rstest]
870 fn test_order_builder_single_limit_convenience() {
871 let params = LimitOrderParamsBuilder::default()
873 .asset(0)
874 .is_buy(true)
875 .px("40000.0".to_string())
876 .sz("0.01".to_string())
877 .reduce_only(false)
878 .tif(TimeInForceRequest::Gtc)
879 .cloid(None)
880 .build()
881 .unwrap();
882
883 let action = OrderBuilder::single_limit_order(params);
884
885 match action {
886 ActionRequest::Order { orders, grouping } => {
887 assert_eq!(orders.len(), 1);
888 assert_eq!(orders[0].a, 0);
889 assert!(orders[0].b);
890 assert_eq!(grouping, "na");
891 }
892 _ => panic!("Expected ActionRequest::Order variant"),
893 }
894 }
895
896 #[rstest]
897 fn test_order_builder_single_trigger_convenience() {
898 let params = TriggerOrderParamsBuilder::default()
900 .asset(1)
901 .is_buy(false)
902 .px("39000.0".to_string())
903 .sz("0.02".to_string())
904 .reduce_only(false)
905 .is_market(true)
906 .trigger_px("39500.0".to_string())
907 .tpsl(TpSlRequest::Sl)
908 .cloid(Some("sl-order".to_string()))
909 .build()
910 .unwrap();
911
912 let action = OrderBuilder::single_trigger_order(params);
913
914 match action {
915 ActionRequest::Order { orders, grouping } => {
916 assert_eq!(orders.len(), 1);
917 assert_eq!(orders[0].a, 1);
918 assert_eq!(orders[0].c, Some("sl-order".to_string()));
919 assert_eq!(grouping, "na");
920 }
921 _ => panic!("Expected ActionRequest::Order variant"),
922 }
923 }
924
925 #[rstest]
926 fn test_order_builder_batch_orders() {
927 let params1 = LimitOrderParams {
929 asset: 0,
930 is_buy: true,
931 px: "40000.0".to_string(),
932 sz: "0.01".to_string(),
933 reduce_only: false,
934 tif: TimeInForceRequest::Gtc,
935 cloid: Some("order-1".to_string()),
936 };
937
938 let params2 = LimitOrderParams {
939 asset: 1,
940 is_buy: false,
941 px: "2000.0".to_string(),
942 sz: "0.5".to_string(),
943 reduce_only: false,
944 tif: TimeInForceRequest::Ioc,
945 cloid: Some("order-2".to_string()),
946 };
947
948 let action = OrderBuilder::new()
949 .grouping(Grouping::NormalTpsl)
950 .push_limit_order(params1)
951 .push_limit_order(params2)
952 .build();
953
954 match action {
955 ActionRequest::Order { orders, grouping } => {
956 assert_eq!(orders.len(), 2);
957 assert_eq!(orders[0].c, Some("order-1".to_string()));
958 assert_eq!(orders[1].c, Some("order-2".to_string()));
959 assert_eq!(grouping, "normalTpsl");
960 }
961 _ => panic!("Expected ActionRequest::Order variant"),
962 }
963 }
964
965 #[rstest]
966 fn test_action_request_constructors() {
967 let order1 = mk_limit_gtc(0);
969 let order2 = mk_limit_gtc(1);
970 let action = ActionRequest::order(vec![order1, order2], "na");
971
972 match action {
973 ActionRequest::Order { orders, grouping } => {
974 assert_eq!(orders.len(), 2);
975 assert_eq!(grouping, "na");
976 }
977 _ => panic!("Expected ActionRequest::Order variant"),
978 }
979
980 let cancels = vec![CancelRequest { a: 0, o: 12345 }];
982 let action = ActionRequest::cancel(cancels);
983 assert!(matches!(action, ActionRequest::Cancel { .. }));
984
985 let cancels = vec![CancelByCloidRequest {
987 asset: 0,
988 cloid: "order-1".to_string(),
989 }];
990 let action = ActionRequest::cancel_by_cloid(cancels);
991 assert!(matches!(action, ActionRequest::CancelByCloid { .. }));
992 }
993
994 #[rstest]
995 #[tokio::test(flavor = "multi_thread")]
996 async fn batcher_sends_on_tick() {
997 let sent: Arc<tokio::sync::Mutex<Vec<u64>>> = Arc::new(tokio::sync::Mutex::new(Vec::new()));
999 let sent_closure = sent.clone();
1000
1001 let send_fn = move |req: HyperliquidWsRequest| -> BoxFuture<'static, Result<()>> {
1002 let sent_inner = sent_closure.clone();
1003 Box::pin(async move {
1004 if let HyperliquidWsRequest::Post { id, .. } = req {
1005 sent_inner.lock().await.push(id);
1006 }
1007 Ok(())
1008 })
1009 };
1010
1011 let batcher = PostBatcher::new(send_fn);
1012
1013 for id in 1..=5u64 {
1015 batcher
1016 .enqueue(ScheduledPost {
1017 id,
1018 request: info_all_mids(),
1019 lane: PostLane::Normal,
1020 })
1021 .await
1022 .unwrap();
1023 }
1024
1025 let sent_check = sent.clone();
1027 wait_until_async(
1028 || {
1029 let sent_inner = sent_check.clone();
1030 async move { sent_inner.lock().await.len() == 5 }
1031 },
1032 Duration::from_secs(2),
1033 )
1034 .await;
1035
1036 let actual = sent.lock().await.clone();
1037 assert_eq!(actual, vec![1, 2, 3, 4, 5]);
1038 }
1039}