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_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    // 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    _tasks: TaskGroup,
175}
176
177impl PostBatcher {
178    /// Spawns two lane tasks that batch-send scheduled posts via `send_fn`.
179    ///
180    /// # Panics
181    ///
182    /// Panics if the new task group rejects either initial lane task.
183    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        // ALO lane: batchy tick, low jitter
192        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        // NORMAL lane: faster tick; adjust as needed
202        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, // sender dropped → terminate lane task
236                    }
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
269// Helpers to classify lane from an action
270pub 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/// Parameters for creating a limit order.
313#[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/// Parameters for creating a trigger order.
325#[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// ORDER builder (single or many)
339#[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    /// Create a limit order with individual parameters (legacy method)
357    #[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    /// Create a limit order using parameters struct
382    #[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    /// Create a trigger order with individual parameters (legacy method)
397    #[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    /// Create a trigger order using parameters struct
426    #[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    /// Create a single limit order action directly (convenience method)
451    ///
452    /// # Example
453    /// ```ignore
454    /// let action = OrderBuilder::single_limit_order(
455    ///     LimitOrderParamsBuilder::default()
456    ///         .asset(0)
457    ///         .is_buy(true)
458    ///         .px("40000.0")
459    ///         .sz("0.01")
460    ///         .reduce_only(false)
461    ///         .tif(TimeInForceRequest::Gtc)
462    ///         .build()
463    ///         .unwrap()
464    /// );
465    /// ```
466    pub fn single_limit_order(params: LimitOrderParams) -> ActionRequest {
467        Self::new().push_limit_order(params).build()
468    }
469
470    /// Create a single trigger order action directly (convenience method)
471    ///
472    /// # Example
473    /// ```ignore
474    /// let action = OrderBuilder::single_trigger_order(
475    ///     TriggerOrderParamsBuilder::default()
476    ///         .asset(0)
477    ///         .is_buy(false)
478    ///         .px("39000.0")
479    ///         .sz("0.01")
480    ///         .reduce_only(false)
481    ///         .is_market(true)
482    ///         .trigger_px("39500.0")
483    ///         .tpsl(TpSlRequest::Sl)
484    ///         .build()
485    ///         .unwrap()
486    /// );
487    /// ```
488    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/// Heuristic classification for action responses.
581#[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                // any non-ALO TIF keeps it in the Normal lane
701                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        // No complete() → ensure we time out and the waiter is removed.
746        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        // After timeout, id should be reusable (cancel dropped the waiter & released the permit).
757        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        // Fill the inflight capacity.
769        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); // keep waiters alive
773        }
774
775        // Next register should block until a permit is freed.
776        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::<()>(); // separate channel for checking
780
781        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        // Confirm the task is trying to register…
788        entered_rx.await.unwrap();
789
790        // …and that it doesn't complete yet (still blocked on permit).
791        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        // Free one permit by cancelling a waiter.
803        router.cancel(0).await;
804
805        // Wait for the blocked register to complete.
806        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        // Test OrderRequestBuilder derived from #[derive(Builder)]
827        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        // Test LimitOrderParamsBuilder
851        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        // Test TriggerOrderParamsBuilder
873        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        // Test OrderBuilder::single_limit_order convenience method
896        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        // Test OrderBuilder::single_trigger_order convenience method
923        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        // Test existing batch order functionality still works
952        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        // Test ActionRequest::order() constructor
992        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        // Test ActionRequest::cancel() constructor
1005        let cancels = vec![CancelRequest { a: 0, o: 12345 }];
1006        let action = ActionRequest::cancel(cancels);
1007        assert!(matches!(action, ActionRequest::Cancel { .. }));
1008
1009        // Test ActionRequest::cancel_by_cloid() constructor
1010        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        // Capture sent ids to prove dispatch happened.
1022        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        // Enqueue a handful of posts into the NORMAL lane; tick is ~50ms.
1038        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        // Wait for all 5 posts to be sent
1050        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}