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_live::task::TaskGroup;
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 _tasks: TaskGroup,
175}
176
177impl PostBatcher {
178 pub fn new<F>(send_fn: F) -> Self
184 where
185 F: Send + 'static + Clone + FnMut(HyperliquidWsRequest) -> BoxFuture<'static, Result<()>>,
186 {
187 let (tx_alo, rx_alo) = mpsc::channel::<ScheduledPost>(1024);
188 let (tx_normal, rx_normal) = mpsc::channel::<ScheduledPost>(4096);
189 let tasks = TaskGroup::new();
190
191 tasks
193 .spawn(Self::run_lane(
194 "ALO",
195 rx_alo,
196 Duration::from_millis(100),
197 send_fn.clone(),
198 ))
199 .expect("new post batcher accepts ALO lane task");
200
201 tasks
203 .spawn(Self::run_lane(
204 "NORMAL",
205 rx_normal,
206 Duration::from_millis(50),
207 send_fn,
208 ))
209 .expect("new post batcher accepts normal lane task");
210
211 Self {
212 tx_alo,
213 tx_normal,
214 _tasks: tasks,
215 }
216 }
217
218 async fn run_lane<F>(
219 lane_name: &'static str,
220 mut rx: mpsc::Receiver<ScheduledPost>,
221 tick: Duration,
222 mut send_fn: F,
223 ) where
224 F: Send + 'static + FnMut(HyperliquidWsRequest) -> BoxFuture<'static, Result<()>>,
225 {
226 let mut pend: Vec<ScheduledPost> = Vec::with_capacity(128);
227 let mut interval = time::interval(tick);
228 interval.set_missed_tick_behavior(time::MissedTickBehavior::Delay);
229
230 loop {
231 tokio::select! {
232 maybe_item = rx.recv() => {
233 match maybe_item {
234 Some(item) => pend.push(item),
235 None => break, }
237 }
238 _ = interval.tick() => {
239 if pend.is_empty() { continue; }
240 let to_send = std::mem::take(&mut pend);
241 for item in to_send {
242 let req = HyperliquidWsRequest::Post { id: item.id, request: item.request.clone() };
243 if let Err(e) = send_fn(req).await {
244 log::error!("Failed to send post: lane={lane_name}, id={}, {e}", item.id);
245 }
246 }
247 }
248 }
249 }
250 log::debug!("Post lane terminated: lane={lane_name}");
251 }
252
253 pub async fn enqueue(&self, item: ScheduledPost) -> Result<()> {
254 match item.lane {
255 PostLane::Alo => self
256 .tx_alo
257 .send(item)
258 .await
259 .map_err(|_| Error::transport("ALO lane closed")),
260 PostLane::Normal => self
261 .tx_normal
262 .send(item)
263 .await
264 .map_err(|_| Error::transport("NORMAL lane closed")),
265 }
266 }
267}
268
269pub fn lane_for_action(action: &ActionRequest) -> PostLane {
271 match action {
272 ActionRequest::Order { orders, .. } => {
273 if orders.is_empty() {
274 return PostLane::Normal;
275 }
276 let all_alo = orders.iter().all(|o| {
277 matches!(
278 o.t,
279 OrderTypeRequest::Limit {
280 tif: TimeInForceRequest::Alo
281 }
282 )
283 });
284
285 if all_alo {
286 PostLane::Alo
287 } else {
288 PostLane::Normal
289 }
290 }
291 _ => PostLane::Normal,
292 }
293}
294
295#[derive(Debug, Clone, Copy, Default)]
296pub enum Grouping {
297 #[default]
298 Na,
299 NormalTpsl,
300 PositionTpsl,
301}
302impl Grouping {
303 pub fn as_str(&self) -> &'static str {
304 match self {
305 Self::Na => "na",
306 Self::NormalTpsl => "normalTpsl",
307 Self::PositionTpsl => "positionTpsl",
308 }
309 }
310}
311
312#[derive(Debug, Clone, Builder)]
314pub struct LimitOrderParams {
315 pub asset: u32,
316 pub is_buy: bool,
317 pub px: String,
318 pub sz: String,
319 pub reduce_only: bool,
320 pub tif: TimeInForceRequest,
321 pub cloid: Option<String>,
322}
323
324#[derive(Debug, Clone, Builder)]
326pub struct TriggerOrderParams {
327 pub asset: u32,
328 pub is_buy: bool,
329 pub px: String,
330 pub sz: String,
331 pub reduce_only: bool,
332 pub is_market: bool,
333 pub trigger_px: String,
334 pub tpsl: TpSlRequest,
335 pub cloid: Option<String>,
336}
337
338#[derive(Debug, Default)]
340pub struct OrderBuilder {
341 orders: Vec<OrderRequest>,
342 grouping: Grouping,
343}
344
345impl OrderBuilder {
346 pub fn new() -> Self {
347 Self::default()
348 }
349
350 #[must_use]
351 pub fn grouping(mut self, g: Grouping) -> Self {
352 self.grouping = g;
353 self
354 }
355
356 #[expect(clippy::too_many_arguments)]
358 #[must_use]
359 pub fn push_limit(
360 self,
361 asset: u32,
362 is_buy: bool,
363 px: &(impl ToString + ?Sized),
364 sz: &(impl ToString + ?Sized),
365 reduce_only: bool,
366 tif: TimeInForceRequest,
367 cloid: Option<String>,
368 ) -> Self {
369 let params = LimitOrderParams {
370 asset,
371 is_buy,
372 px: px.to_string(),
373 sz: sz.to_string(),
374 reduce_only,
375 tif,
376 cloid,
377 };
378 self.push_limit_order(params)
379 }
380
381 #[must_use]
383 pub fn push_limit_order(mut self, params: LimitOrderParams) -> Self {
384 self.orders.push(OrderRequest {
385 a: params.asset,
386 b: params.is_buy,
387 p: params.px,
388 s: params.sz,
389 r: params.reduce_only,
390 t: OrderTypeRequest::Limit { tif: params.tif },
391 c: params.cloid,
392 });
393 self
394 }
395
396 #[expect(clippy::too_many_arguments)]
398 #[must_use]
399 pub fn push_trigger(
400 self,
401 asset: u32,
402 is_buy: bool,
403 px: &(impl ToString + ?Sized),
404 sz: &(impl ToString + ?Sized),
405 reduce_only: bool,
406 is_market: bool,
407 trigger_px: &(impl ToString + ?Sized),
408 tpsl: TpSlRequest,
409 cloid: Option<String>,
410 ) -> Self {
411 let params = TriggerOrderParams {
412 asset,
413 is_buy,
414 px: px.to_string(),
415 sz: sz.to_string(),
416 reduce_only,
417 is_market,
418 trigger_px: trigger_px.to_string(),
419 tpsl,
420 cloid,
421 };
422 self.push_trigger_order(params)
423 }
424
425 #[must_use]
427 pub fn push_trigger_order(mut self, params: TriggerOrderParams) -> Self {
428 self.orders.push(OrderRequest {
429 a: params.asset,
430 b: params.is_buy,
431 p: params.px,
432 s: params.sz,
433 r: params.reduce_only,
434 t: OrderTypeRequest::Trigger {
435 is_market: params.is_market,
436 trigger_px: params.trigger_px,
437 tpsl: params.tpsl,
438 },
439 c: params.cloid,
440 });
441 self
442 }
443 pub fn build(self) -> ActionRequest {
444 ActionRequest::Order {
445 orders: self.orders,
446 grouping: self.grouping.as_str().to_string(),
447 }
448 }
449
450 pub fn single_limit_order(params: LimitOrderParams) -> ActionRequest {
467 Self::new().push_limit_order(params).build()
468 }
469
470 pub fn single_trigger_order(params: TriggerOrderParams) -> ActionRequest {
489 Self::new().push_trigger_order(params).build()
490 }
491}
492
493pub fn cancel_many(cancels: Vec<(u32, u64)>) -> ActionRequest {
494 ActionRequest::Cancel {
495 cancels: cancels
496 .into_iter()
497 .map(|(a, o)| CancelRequest { a, o })
498 .collect(),
499 fast: None,
500 }
501}
502pub fn cancel_by_cloid(asset: u32, cloid: impl Into<String>) -> ActionRequest {
503 ActionRequest::CancelByCloid {
504 cancels: vec![CancelByCloidRequest {
505 asset,
506 cloid: cloid.into(),
507 }],
508 fast: None,
509 }
510}
511pub fn modify(oid: u64, new_order: OrderRequest) -> ActionRequest {
512 ActionRequest::Modify {
513 modifies: vec![ModifyRequest {
514 oid,
515 order: new_order,
516 }],
517 }
518}
519
520pub fn info_l2_book(coin: &str) -> PostRequest {
521 PostRequest::Info {
522 payload: serde_json::json!({"type": HyperliquidInfoRequestType::L2Book.as_str(), "coin": coin}),
523 }
524}
525
526pub fn info_all_mids() -> PostRequest {
527 PostRequest::Info {
528 payload: serde_json::json!({"type": HyperliquidInfoRequestType::AllMids.as_str()}),
529 }
530}
531
532pub fn info_order_status(user: &str, oid: u64) -> PostRequest {
533 PostRequest::Info {
534 payload: serde_json::json!({"type": HyperliquidInfoRequestType::OrderStatus.as_str(), "user": user, "oid": oid}),
535 }
536}
537
538pub fn info_open_orders(user: &str, frontend: Option<bool>) -> PostRequest {
539 let mut body =
540 serde_json::json!({"type": HyperliquidInfoRequestType::OpenOrders.as_str(), "user": user});
541
542 if let Some(fe) = frontend {
543 body["frontend"] = serde_json::json!(fe);
544 }
545 PostRequest::Info { payload: body }
546}
547
548pub fn info_user_fills(user: &str, aggregate_by_time: Option<bool>) -> PostRequest {
549 let mut body =
550 serde_json::json!({"type": HyperliquidInfoRequestType::UserFills.as_str(), "user": user});
551
552 if let Some(agg) = aggregate_by_time {
553 body["aggregateByTime"] = serde_json::json!(agg);
554 }
555 PostRequest::Info { payload: body }
556}
557
558pub fn info_user_rate_limit(user: &str) -> PostRequest {
559 PostRequest::Info {
560 payload: serde_json::json!({"type": HyperliquidInfoRequestType::UserRateLimit.as_str(), "user": user}),
561 }
562}
563
564pub fn info_candle(coin: &str, interval: &str) -> PostRequest {
565 PostRequest::Info {
566 payload: serde_json::json!({"type": HyperliquidInfoRequestType::Candle.as_str(), "coin": coin, "interval": interval}),
567 }
568}
569
570pub fn parse_l2_book(payload: &serde_json::Value) -> Result<HyperliquidL2Book> {
571 serde_json::from_value(payload.clone()).map_err(Error::Serde)
572}
573pub fn parse_user_fills(payload: &serde_json::Value) -> Result<HyperliquidFills> {
574 serde_json::from_value(payload.clone()).map_err(Error::Serde)
575}
576pub fn parse_order_status(payload: &serde_json::Value) -> Result<HyperliquidOrderStatus> {
577 serde_json::from_value(payload.clone()).map_err(Error::Serde)
578}
579
580#[derive(Debug)]
582pub enum ActionOutcome<'a> {
583 Resting {
584 oid: u64,
585 },
586 Filled {
587 total_sz: &'a str,
588 avg_px: &'a str,
589 oid: Option<u64>,
590 },
591 Error {
592 msg: &'a str,
593 },
594 Unknown(&'a serde_json::Value),
595}
596pub fn classify_action_payload(payload: &serde_json::Value) -> ActionOutcome<'_> {
597 if let Some(oid) = payload.get("oid").and_then(|v| v.as_u64()) {
598 if let (Some(total_sz), Some(avg_px)) = (
599 payload.get("totalSz").and_then(|v| v.as_str()),
600 payload.get("avgPx").and_then(|v| v.as_str()),
601 ) {
602 return ActionOutcome::Filled {
603 total_sz,
604 avg_px,
605 oid: Some(oid),
606 };
607 }
608 return ActionOutcome::Resting { oid };
609 }
610
611 if let (Some(total_sz), Some(avg_px)) = (
612 payload.get("totalSz").and_then(|v| v.as_str()),
613 payload.get("avgPx").and_then(|v| v.as_str()),
614 ) {
615 return ActionOutcome::Filled {
616 total_sz,
617 avg_px,
618 oid: None,
619 };
620 }
621
622 if let Some(msg) = payload
623 .get("error")
624 .and_then(|v| v.as_str())
625 .or_else(|| payload.get("message").and_then(|v| v.as_str()))
626 {
627 return ActionOutcome::Error { msg };
628 }
629 ActionOutcome::Unknown(payload)
630}
631
632#[derive(Clone, Debug)]
633pub struct WsSender {
634 inner: mpsc::Sender<HyperliquidWsRequest>,
635}
636
637impl WsSender {
638 pub fn new(tx: mpsc::Sender<HyperliquidWsRequest>) -> Self {
639 Self { inner: tx }
640 }
641
642 pub async fn send(&self, req: HyperliquidWsRequest) -> Result<()> {
643 self.inner
644 .send(req)
645 .await
646 .map_err(|_| Error::transport("WebSocket sender closed"))
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use std::sync::atomic::AtomicUsize;
653
654 use nautilus_common::{live::get_runtime, testing::wait_until_async};
655 use rstest::rstest;
656 use tokio::{
657 sync::oneshot,
658 time::{Duration, timeout},
659 };
660
661 use super::*;
662 use crate::{
663 common::consts::INFLIGHT_MAX,
664 websocket::messages::{
665 ActionRequest, CancelByCloidRequest, CancelRequest, HyperliquidWsRequest, OrderRequest,
666 OrderRequestBuilder, OrderTypeRequest, TimeInForceRequest,
667 },
668 };
669
670 struct DropCounter(Arc<AtomicUsize>);
671
672 impl Drop for DropCounter {
673 fn drop(&mut self) {
674 self.0.fetch_add(1, Ordering::Relaxed);
675 }
676 }
677
678 fn mk_limit_alo(asset: u32) -> OrderRequest {
679 OrderRequest {
680 a: asset,
681 b: true,
682 p: "1".to_string(),
683 s: "1".to_string(),
684 r: false,
685 t: OrderTypeRequest::Limit {
686 tif: TimeInForceRequest::Alo,
687 },
688 c: None,
689 }
690 }
691
692 fn mk_limit_gtc(asset: u32) -> OrderRequest {
693 OrderRequest {
694 a: asset,
695 b: true,
696 p: "1".to_string(),
697 s: "1".to_string(),
698 r: false,
699 t: OrderTypeRequest::Limit {
700 tif: TimeInForceRequest::Gtc,
702 },
703 c: None,
704 }
705 }
706
707 #[rstest]
708 #[tokio::test]
709 async fn test_ws_sender_forwards_and_reports_closed_channel() {
710 let (tx, mut rx) = mpsc::channel(1);
711 let sender = WsSender::new(tx);
712
713 sender.send(HyperliquidWsRequest::Ping).await.unwrap();
714 assert!(matches!(rx.recv().await, Some(HyperliquidWsRequest::Ping)));
715
716 drop(rx);
717 let error = sender.send(HyperliquidWsRequest::Ping).await.unwrap_err();
718 assert_eq!(
719 error.to_string(),
720 "transport error: WebSocket sender closed"
721 );
722 }
723
724 #[rstest]
725 #[tokio::test(flavor = "multi_thread")]
726 async fn register_duplicate_id_errors() {
727 let router = PostRouter::new();
728 let _rx = router.register(42).await.expect("first register OK");
729
730 let err = router.register(42).await.expect_err("duplicate must error");
731 let msg = err.to_string().to_lowercase();
732 assert!(
733 msg.contains("already") || msg.contains("duplicate"),
734 "unexpected error: {msg}"
735 );
736 }
737
738 #[rstest]
739 #[tokio::test(flavor = "multi_thread")]
740 async fn timeout_cancels_and_allows_reregister() {
741 let router = PostRouter::new();
742 let id = 7;
743
744 let rx = router.register(id).await.unwrap();
745 let err = router
747 .await_with_timeout(id, rx, Duration::from_millis(25))
748 .await
749 .expect_err("should timeout");
750 assert!(
751 err.to_string().to_lowercase().contains("timeout")
752 || err.to_string().to_lowercase().contains("closed"),
753 "unexpected error kind: {err}"
754 );
755
756 let _rx2 = router
758 .register(id)
759 .await
760 .expect("id should be reusable after timeout cancel");
761 }
762
763 #[rstest]
764 #[tokio::test(flavor = "multi_thread")]
765 async fn inflight_cap_blocks_then_unblocks() {
766 let router = PostRouter::new();
767
768 let mut rxs = Vec::with_capacity(INFLIGHT_MAX);
770 for i in 0..INFLIGHT_MAX {
771 let rx = router.register(i as u64).await.unwrap();
772 rxs.push(rx); }
774
775 let router2 = Arc::clone(&router);
777 let (entered_tx, entered_rx) = oneshot::channel::<()>();
778 let (done_tx, done_rx) = oneshot::channel::<()>();
779 let (check_tx, check_rx) = oneshot::channel::<()>(); get_runtime().spawn(async move {
782 let _ = entered_tx.send(());
783 let _rx = router2.register(9_999_999).await.unwrap();
784 let _ = done_tx.send(());
785 });
786
787 entered_rx.await.unwrap();
789
790 get_runtime().spawn(async move {
792 if done_rx.await.is_ok() {
793 let _ = check_tx.send(());
794 }
795 });
796
797 assert!(
798 timeout(Duration::from_millis(50), check_rx).await.is_err(),
799 "should still be blocked while at cap"
800 );
801
802 router.cancel(0).await;
804
805 tokio::time::sleep(Duration::from_millis(100)).await;
807 }
808
809 #[rstest(
810 orders, expected,
811 case::all_alo(vec![mk_limit_alo(0), mk_limit_alo(1)], PostLane::Alo),
812 case::mixed_alo_gtc(vec![mk_limit_alo(0), mk_limit_gtc(1)], PostLane::Normal),
813 case::all_gtc(vec![mk_limit_gtc(0), mk_limit_gtc(1)], PostLane::Normal),
814 case::empty(vec![], PostLane::Normal),
815 )]
816 fn lane_classifier_cases(orders: Vec<OrderRequest>, expected: PostLane) {
817 let action = ActionRequest::Order {
818 orders,
819 grouping: "na".to_string(),
820 };
821 assert_eq!(lane_for_action(&action), expected);
822 }
823
824 #[rstest]
825 fn test_order_request_builder() {
826 let order = OrderRequestBuilder::default()
828 .a(0)
829 .b(true)
830 .p("40000.0".to_string())
831 .s("0.01".to_string())
832 .r(false)
833 .t(OrderTypeRequest::Limit {
834 tif: TimeInForceRequest::Gtc,
835 })
836 .c(Some("test-order-1".to_string()))
837 .build()
838 .expect("should build order");
839
840 assert_eq!(order.a, 0);
841 assert!(order.b);
842 assert_eq!(order.p, "40000.0");
843 assert_eq!(order.s, "0.01");
844 assert!(!order.r);
845 assert_eq!(order.c, Some("test-order-1".to_string()));
846 }
847
848 #[rstest]
849 fn test_limit_order_params_builder() {
850 let params = LimitOrderParamsBuilder::default()
852 .asset(0)
853 .is_buy(true)
854 .px("40000.0".to_string())
855 .sz("0.01".to_string())
856 .reduce_only(false)
857 .tif(TimeInForceRequest::Alo)
858 .cloid(Some("test-limit-1".to_string()))
859 .build()
860 .expect("should build limit params");
861
862 assert_eq!(params.asset, 0);
863 assert!(params.is_buy);
864 assert_eq!(params.px, "40000.0");
865 assert_eq!(params.sz, "0.01");
866 assert!(!params.reduce_only);
867 assert_eq!(params.cloid, Some("test-limit-1".to_string()));
868 }
869
870 #[rstest]
871 fn test_trigger_order_params_builder() {
872 let params = TriggerOrderParamsBuilder::default()
874 .asset(1)
875 .is_buy(false)
876 .px("39000.0".to_string())
877 .sz("0.02".to_string())
878 .reduce_only(false)
879 .is_market(true)
880 .trigger_px("39500.0".to_string())
881 .tpsl(TpSlRequest::Sl)
882 .cloid(Some("test-trigger-1".to_string()))
883 .build()
884 .expect("should build trigger params");
885
886 assert_eq!(params.asset, 1);
887 assert!(!params.is_buy);
888 assert_eq!(params.px, "39000.0");
889 assert!(params.is_market);
890 assert_eq!(params.trigger_px, "39500.0");
891 }
892
893 #[rstest]
894 fn test_order_builder_single_limit_convenience() {
895 let params = LimitOrderParamsBuilder::default()
897 .asset(0)
898 .is_buy(true)
899 .px("40000.0".to_string())
900 .sz("0.01".to_string())
901 .reduce_only(false)
902 .tif(TimeInForceRequest::Gtc)
903 .cloid(None)
904 .build()
905 .unwrap();
906
907 let action = OrderBuilder::single_limit_order(params);
908
909 match action {
910 ActionRequest::Order { orders, grouping } => {
911 assert_eq!(orders.len(), 1);
912 assert_eq!(orders[0].a, 0);
913 assert!(orders[0].b);
914 assert_eq!(grouping, "na");
915 }
916 _ => panic!("Expected ActionRequest::Order variant"),
917 }
918 }
919
920 #[rstest]
921 fn test_order_builder_single_trigger_convenience() {
922 let params = TriggerOrderParamsBuilder::default()
924 .asset(1)
925 .is_buy(false)
926 .px("39000.0".to_string())
927 .sz("0.02".to_string())
928 .reduce_only(false)
929 .is_market(true)
930 .trigger_px("39500.0".to_string())
931 .tpsl(TpSlRequest::Sl)
932 .cloid(Some("sl-order".to_string()))
933 .build()
934 .unwrap();
935
936 let action = OrderBuilder::single_trigger_order(params);
937
938 match action {
939 ActionRequest::Order { orders, grouping } => {
940 assert_eq!(orders.len(), 1);
941 assert_eq!(orders[0].a, 1);
942 assert_eq!(orders[0].c, Some("sl-order".to_string()));
943 assert_eq!(grouping, "na");
944 }
945 _ => panic!("Expected ActionRequest::Order variant"),
946 }
947 }
948
949 #[rstest]
950 fn test_order_builder_batch_orders() {
951 let params1 = LimitOrderParams {
953 asset: 0,
954 is_buy: true,
955 px: "40000.0".to_string(),
956 sz: "0.01".to_string(),
957 reduce_only: false,
958 tif: TimeInForceRequest::Gtc,
959 cloid: Some("order-1".to_string()),
960 };
961
962 let params2 = LimitOrderParams {
963 asset: 1,
964 is_buy: false,
965 px: "2000.0".to_string(),
966 sz: "0.5".to_string(),
967 reduce_only: false,
968 tif: TimeInForceRequest::Ioc,
969 cloid: Some("order-2".to_string()),
970 };
971
972 let action = OrderBuilder::new()
973 .grouping(Grouping::NormalTpsl)
974 .push_limit_order(params1)
975 .push_limit_order(params2)
976 .build();
977
978 match action {
979 ActionRequest::Order { orders, grouping } => {
980 assert_eq!(orders.len(), 2);
981 assert_eq!(orders[0].c, Some("order-1".to_string()));
982 assert_eq!(orders[1].c, Some("order-2".to_string()));
983 assert_eq!(grouping, "normalTpsl");
984 }
985 _ => panic!("Expected ActionRequest::Order variant"),
986 }
987 }
988
989 #[rstest]
990 fn test_action_request_constructors() {
991 let order1 = mk_limit_gtc(0);
993 let order2 = mk_limit_gtc(1);
994 let action = ActionRequest::order(vec![order1, order2], "na");
995
996 match action {
997 ActionRequest::Order { orders, grouping } => {
998 assert_eq!(orders.len(), 2);
999 assert_eq!(grouping, "na");
1000 }
1001 _ => panic!("Expected ActionRequest::Order variant"),
1002 }
1003
1004 let cancels = vec![CancelRequest { a: 0, o: 12345 }];
1006 let action = ActionRequest::cancel(cancels);
1007 assert!(matches!(action, ActionRequest::Cancel { .. }));
1008
1009 let cancels = vec![CancelByCloidRequest {
1011 asset: 0,
1012 cloid: "order-1".to_string(),
1013 }];
1014 let action = ActionRequest::cancel_by_cloid(cancels);
1015 assert!(matches!(action, ActionRequest::CancelByCloid { .. }));
1016 }
1017
1018 #[rstest]
1019 #[tokio::test(flavor = "multi_thread")]
1020 async fn batcher_sends_on_tick() {
1021 let sent: Arc<tokio::sync::Mutex<Vec<u64>>> = Arc::new(tokio::sync::Mutex::new(Vec::new()));
1023 let sent_closure = sent.clone();
1024
1025 let send_fn = move |req: HyperliquidWsRequest| -> BoxFuture<'static, Result<()>> {
1026 let sent_inner = sent_closure.clone();
1027 Box::pin(async move {
1028 if let HyperliquidWsRequest::Post { id, .. } = req {
1029 sent_inner.lock().await.push(id);
1030 }
1031 Ok(())
1032 })
1033 };
1034
1035 let batcher = PostBatcher::new(send_fn);
1036
1037 for id in 1..=5u64 {
1039 batcher
1040 .enqueue(ScheduledPost {
1041 id,
1042 request: info_all_mids(),
1043 lane: PostLane::Normal,
1044 })
1045 .await
1046 .unwrap();
1047 }
1048
1049 let sent_check = sent.clone();
1051 wait_until_async(
1052 || {
1053 let sent_inner = sent_check.clone();
1054 async move { sent_inner.lock().await.len() == 5 }
1055 },
1056 Duration::from_secs(2),
1057 )
1058 .await;
1059
1060 let actual = sent.lock().await.clone();
1061 assert_eq!(actual, vec![1, 2, 3, 4, 5]);
1062 }
1063
1064 #[rstest]
1065 #[tokio::test]
1066 async fn test_batcher_drop_aborts_lane_tasks() {
1067 let started = Arc::new(AtomicUsize::new(0));
1068 let dropped = Arc::new(AtomicUsize::new(0));
1069 let started_send = Arc::clone(&started);
1070 let dropped_send = Arc::clone(&dropped);
1071 let send_fn = move |_req: HyperliquidWsRequest| -> BoxFuture<'static, Result<()>> {
1072 let started = Arc::clone(&started_send);
1073 let dropped = Arc::clone(&dropped_send);
1074 Box::pin(async move {
1075 let _drop_counter = DropCounter(dropped);
1076 started.fetch_add(1, Ordering::Relaxed);
1077 std::future::pending::<Result<()>>().await
1078 })
1079 };
1080 let batcher = PostBatcher::new(send_fn);
1081
1082 for (id, lane) in [(1, PostLane::Alo), (2, PostLane::Normal)] {
1083 batcher
1084 .enqueue(ScheduledPost {
1085 id,
1086 request: info_all_mids(),
1087 lane,
1088 })
1089 .await
1090 .unwrap();
1091 }
1092 let started_check = Arc::clone(&started);
1093 wait_until_async(
1094 || {
1095 let started = Arc::clone(&started_check);
1096 async move { started.load(Ordering::Relaxed) == 2 }
1097 },
1098 Duration::from_secs(1),
1099 )
1100 .await;
1101
1102 drop(batcher);
1103
1104 let dropped_check = Arc::clone(&dropped);
1105 wait_until_async(
1106 || {
1107 let dropped = Arc::clone(&dropped_check);
1108 async move { dropped.load(Ordering::Relaxed) == 2 }
1109 },
1110 Duration::from_secs(1),
1111 )
1112 .await;
1113 assert_eq!(dropped.load(Ordering::Relaxed), 2);
1114 }
1115}