Skip to main content

nautilus_hyperliquid/websocket/
post.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 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    // When this is dropped, the permit is released, shrinking inflight
49    _permit: OwnedSemaphorePermit,
50}
51
52#[derive(Debug)]
53pub struct PostRouter {
54    inner: Mutex<AHashMap<u64, Waiter>>,
55    inflight: Arc<Semaphore>, // hard cap per HL docs (e.g., 100)
56}
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    /// Registers interest in a post id, enforcing inflight cap.
73    pub async fn register(&self, id: u64) -> Result<oneshot::Receiver<PostResponse>> {
74        // Acquire and retain a permit per inflight call
75        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    /// Completes a waiting caller when a response arrives (releases inflight via Waiter drop).
98    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            // waiter drops here → permit released
110        } else {
111            log::warn!("Post response with unknown id (late/duplicate?): id={id}");
112        }
113    }
114
115    /// Cancel a pending id (e.g., timeout); quietly succeed if id wasn't present.
116    pub async fn cancel(&self, id: u64) {
117        let _ = {
118            let mut map = self.inner.lock().await;
119            map.remove(&id)
120        };
121        // Waiter (and its permit) drop here if it existed
122    }
123
124    /// Await a response with timeout. On timeout or closed channel, cancels the id.
125    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,    // Post-only orders
160    Normal, // IOC/GTC + info + anything else
161}
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    /// Spawns two lane tasks that batch-send scheduled posts via `send_fn`.
178    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        // ALO lane: batchy tick, low jitter
186        get_runtime().spawn(Self::run_lane(
187            "ALO",
188            rx_alo,
189            Duration::from_millis(100),
190            send_fn.clone(),
191        ));
192
193        // NORMAL lane: faster tick; adjust as needed
194        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, // sender dropped → terminate lane task
222                    }
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
255// Helpers to classify lane from an action
256pub 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/// Parameters for creating a limit order.
299#[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/// Parameters for creating a trigger order.
311#[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// ORDER builder (single or many)
325#[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    /// Create a limit order with individual parameters (legacy method)
343    #[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    /// Create a limit order using parameters struct
368    #[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    /// Create a trigger order with individual parameters (legacy method)
383    #[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    /// Create a trigger order using parameters struct
412    #[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    /// Create a single limit order action directly (convenience method)
437    ///
438    /// # Example
439    /// ```ignore
440    /// let action = OrderBuilder::single_limit_order(
441    ///     LimitOrderParamsBuilder::default()
442    ///         .asset(0)
443    ///         .is_buy(true)
444    ///         .px("40000.0")
445    ///         .sz("0.01")
446    ///         .reduce_only(false)
447    ///         .tif(TimeInForceRequest::Gtc)
448    ///         .build()
449    ///         .unwrap()
450    /// );
451    /// ```
452    pub fn single_limit_order(params: LimitOrderParams) -> ActionRequest {
453        Self::new().push_limit_order(params).build()
454    }
455
456    /// Create a single trigger order action directly (convenience method)
457    ///
458    /// # Example
459    /// ```ignore
460    /// let action = OrderBuilder::single_trigger_order(
461    ///     TriggerOrderParamsBuilder::default()
462    ///         .asset(0)
463    ///         .is_buy(false)
464    ///         .px("39000.0")
465    ///         .sz("0.01")
466    ///         .reduce_only(false)
467    ///         .is_market(true)
468    ///         .trigger_px("39500.0")
469    ///         .tpsl(TpSlRequest::Sl)
470    ///         .build()
471    ///         .unwrap()
472    /// );
473    /// ```
474    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/// Heuristic classification for action responses.
567#[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                // any non-ALO TIF keeps it in the Normal lane
677                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        // No complete() → ensure we time out and the waiter is removed.
722        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        // After timeout, id should be reusable (cancel dropped the waiter & released the permit).
733        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        // Fill the inflight capacity.
745        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); // keep waiters alive
749        }
750
751        // Next register should block until a permit is freed.
752        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::<()>(); // separate channel for checking
756
757        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        // Confirm the task is trying to register…
764        entered_rx.await.unwrap();
765
766        // …and that it doesn't complete yet (still blocked on permit).
767        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        // Free one permit by cancelling a waiter.
779        router.cancel(0).await;
780
781        // Wait for the blocked register to complete.
782        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        // Test OrderRequestBuilder derived from #[derive(Builder)]
803        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        // Test LimitOrderParamsBuilder
827        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        // Test TriggerOrderParamsBuilder
849        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        // Test OrderBuilder::single_limit_order convenience method
872        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        // Test OrderBuilder::single_trigger_order convenience method
899        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        // Test existing batch order functionality still works
928        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        // Test ActionRequest::order() constructor
968        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        // Test ActionRequest::cancel() constructor
981        let cancels = vec![CancelRequest { a: 0, o: 12345 }];
982        let action = ActionRequest::cancel(cancels);
983        assert!(matches!(action, ActionRequest::Cancel { .. }));
984
985        // Test ActionRequest::cancel_by_cloid() constructor
986        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        // Capture sent ids to prove dispatch happened.
998        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        // Enqueue a handful of posts into the NORMAL lane; tick is ~50ms.
1014        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        // Wait for all 5 posts to be sent
1026        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}