Skip to main content

hl_types/
order.rs

1use crate::HlError;
2use rust_decimal::Decimal;
3use serde::de::{self, MapAccess, Visitor};
4use serde::ser::SerializeMap;
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use std::fmt;
7
8/// Order side: buy or sell.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum Side {
12    /// Buy side.
13    Buy,
14    /// Sell side.
15    Sell,
16}
17
18impl Side {
19    /// Returns `true` if this is the buy side.
20    pub fn is_buy(self) -> bool {
21        matches!(self, Side::Buy)
22    }
23
24    /// Create a [`Side`] from a boolean `is_buy` flag.
25    pub fn from_is_buy(is_buy: bool) -> Self {
26        if is_buy {
27            Side::Buy
28        } else {
29            Side::Sell
30        }
31    }
32}
33
34impl fmt::Display for Side {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            Side::Buy => write!(f, "buy"),
38            Side::Sell => write!(f, "sell"),
39        }
40    }
41}
42
43/// Time-in-force for limit orders.
44///
45/// Wire format uses PascalCase: `"Gtc"`, `"Ioc"`, `"Alo"`.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
47#[non_exhaustive]
48pub enum Tif {
49    /// Good-til-cancelled.
50    Gtc,
51    /// Immediate-or-cancel.
52    Ioc,
53    /// Add-liquidity-only (post-only).
54    Alo,
55}
56
57impl fmt::Display for Tif {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Tif::Gtc => write!(f, "Gtc"),
61            Tif::Ioc => write!(f, "Ioc"),
62            Tif::Alo => write!(f, "Alo"),
63        }
64    }
65}
66
67/// Trigger order type: stop-loss or take-profit.
68///
69/// Wire format uses lowercase: `"sl"`, `"tp"`.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum Tpsl {
73    /// Stop-loss trigger.
74    Sl,
75    /// Take-profit trigger.
76    Tp,
77}
78
79impl fmt::Display for Tpsl {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Tpsl::Sl => write!(f, "sl"),
83            Tpsl::Tp => write!(f, "tp"),
84        }
85    }
86}
87
88/// Position side: long or short.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum PositionSide {
92    /// Long position.
93    Long,
94    /// Short position.
95    Short,
96}
97
98impl fmt::Display for PositionSide {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self {
101            PositionSide::Long => write!(f, "long"),
102            PositionSide::Short => write!(f, "short"),
103        }
104    }
105}
106
107/// Order status returned by the exchange.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110#[non_exhaustive]
111pub enum OrderStatus {
112    /// Fully filled.
113    Filled,
114    /// Partially filled.
115    Partial,
116    /// Resting on the book.
117    Open,
118    /// Rejected by the exchange.
119    Rejected,
120    /// Triggered as stop-loss.
121    TriggerSl,
122    /// Triggered as take-profit.
123    TriggerTp,
124}
125
126impl fmt::Display for OrderStatus {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            OrderStatus::Filled => write!(f, "filled"),
130            OrderStatus::Partial => write!(f, "partial"),
131            OrderStatus::Open => write!(f, "open"),
132            OrderStatus::Rejected => write!(f, "rejected"),
133            OrderStatus::TriggerSl => write!(f, "trigger_sl"),
134            OrderStatus::TriggerTp => write!(f, "trigger_tp"),
135        }
136    }
137}
138
139/// Order grouping for a bulk `order` action.
140///
141/// Controls how the exchange links the orders in a single action. `Na` (the
142/// default) treats them as independent. `NormalTpsl` links a parent entry order
143/// (index 0) with its take-profit / stop-loss children as an OCO bracket;
144/// `PositionTpsl` attaches the TP/SL to the whole position.
145///
146/// Wire format: `"na"`, `"normalTpsl"`, `"positionTpsl"`.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
148#[non_exhaustive]
149pub enum Grouping {
150    /// Independent orders (no bracket linkage).
151    #[default]
152    Na,
153    /// TP/SL bracket linked to a parent entry order (parent must be index 0).
154    NormalTpsl,
155    /// TP/SL attached to the whole position.
156    PositionTpsl,
157}
158
159impl Grouping {
160    /// The exact wire string for this grouping.
161    pub fn as_str(&self) -> &'static str {
162        match self {
163            Grouping::Na => "na",
164            Grouping::NormalTpsl => "normalTpsl",
165            Grouping::PositionTpsl => "positionTpsl",
166        }
167    }
168}
169
170impl fmt::Display for Grouping {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        write!(f, "{}", self.as_str())
173    }
174}
175
176/// Wire format for an order sent to the Hyperliquid exchange.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179#[non_exhaustive]
180pub struct OrderWire {
181    /// Asset index (perp index or spot index with offset).
182    pub asset: u32,
183    /// Whether this is a buy order.
184    pub is_buy: bool,
185    /// Limit price as a decimal string.
186    pub limit_px: String,
187    /// Size as a decimal string.
188    pub sz: String,
189    /// Whether the order is reduce-only.
190    pub reduce_only: bool,
191    /// Order type wire format.
192    pub order_type: OrderTypeWire,
193    /// Optional client order ID.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub cloid: Option<String>,
196}
197
198/// Builder for constructing [`OrderWire`] instances.
199///
200/// Use the convenience constructors [`OrderWire::limit_buy`],
201/// [`OrderWire::limit_sell`], [`OrderWire::trigger_buy`], or
202/// [`OrderWire::trigger_sell`] to start building.
203#[derive(Debug, Clone)]
204pub struct OrderWireBuilder {
205    asset: u32,
206    is_buy: bool,
207    limit_px: String,
208    sz: String,
209    reduce_only: bool,
210    order_type: OrderTypeWire,
211    cloid: Option<String>,
212}
213
214impl OrderWireBuilder {
215    /// Set the time-in-force (only meaningful for limit orders).
216    ///
217    /// For trigger orders this is a no-op.
218    pub fn tif(mut self, tif: Tif) -> Self {
219        if let OrderTypeWire::Limit(ref mut limit) = self.order_type {
220            limit.tif = tif;
221        }
222        self
223    }
224
225    /// Set the client order ID.
226    pub fn cloid(mut self, cloid: impl Into<String>) -> Self {
227        self.cloid = Some(cloid.into());
228        self
229    }
230
231    /// Mark the order as reduce-only.
232    pub fn reduce_only(mut self, reduce_only: bool) -> Self {
233        self.reduce_only = reduce_only;
234        self
235    }
236
237    /// Build the final [`OrderWire`], validating that price and size are positive.
238    pub fn build(self) -> Result<OrderWire, HlError> {
239        let px: Decimal = self
240            .limit_px
241            .parse()
242            .map_err(|_| HlError::Parse(format!("invalid price: {}", self.limit_px)))?;
243        if px <= Decimal::ZERO {
244            return Err(HlError::Parse(format!(
245                "price must be positive, got: {}",
246                self.limit_px
247            )));
248        }
249        let sz: Decimal = self
250            .sz
251            .parse()
252            .map_err(|_| HlError::Parse(format!("invalid size: {}", self.sz)))?;
253        if sz <= Decimal::ZERO {
254            return Err(HlError::Parse(format!(
255                "size must be positive, got: {}",
256                self.sz
257            )));
258        }
259        Ok(OrderWire {
260            asset: self.asset,
261            is_buy: self.is_buy,
262            limit_px: self.limit_px,
263            sz: self.sz,
264            reduce_only: self.reduce_only,
265            order_type: self.order_type,
266            cloid: self.cloid,
267        })
268    }
269}
270
271impl OrderWire {
272    /// Start building a limit buy order.
273    ///
274    /// Defaults to `Tif::Gtc`, `reduce_only = false`, no `cloid`.
275    ///
276    /// # Example
277    ///
278    /// ```
279    /// use hl_types::{OrderWire, Tif};
280    /// use rust_decimal::Decimal;
281    /// use std::str::FromStr;
282    ///
283    /// let order = OrderWire::limit_buy(0, Decimal::from(90000), Decimal::from_str("0.001").unwrap())
284    ///     .tif(Tif::Gtc)
285    ///     .cloid("my-order-1")
286    ///     .build()
287    ///     .unwrap();
288    ///
289    /// assert!(order.is_buy);
290    /// assert_eq!(order.limit_px, "90000");
291    /// ```
292    pub fn limit_buy(asset: u32, limit_px: Decimal, sz: Decimal) -> OrderWireBuilder {
293        OrderWireBuilder {
294            asset,
295            is_buy: true,
296            limit_px: limit_px.normalize().to_string(),
297            sz: sz.normalize().to_string(),
298            reduce_only: false,
299            order_type: OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc }),
300            cloid: None,
301        }
302    }
303
304    /// Start building a limit sell order.
305    ///
306    /// Defaults to `Tif::Gtc`, `reduce_only = false`, no `cloid`.
307    pub fn limit_sell(asset: u32, limit_px: Decimal, sz: Decimal) -> OrderWireBuilder {
308        OrderWireBuilder {
309            asset,
310            is_buy: false,
311            limit_px: limit_px.normalize().to_string(),
312            sz: sz.normalize().to_string(),
313            reduce_only: false,
314            order_type: OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc }),
315            cloid: None,
316        }
317    }
318
319    /// Start building a trigger buy order (e.g. stop-loss or take-profit).
320    ///
321    /// Trigger orders fire as market orders when the trigger price is hit.
322    /// Defaults to `reduce_only = true`, no `cloid`.
323    pub fn trigger_buy(
324        asset: u32,
325        trigger_px: Decimal,
326        sz: Decimal,
327        tpsl: Tpsl,
328    ) -> OrderWireBuilder {
329        let trigger_px_str = trigger_px.normalize().to_string();
330        OrderWireBuilder {
331            asset,
332            is_buy: true,
333            limit_px: trigger_px_str.clone(),
334            sz: sz.normalize().to_string(),
335            reduce_only: true,
336            order_type: OrderTypeWire::Trigger(TriggerOrderType {
337                trigger_px: trigger_px_str,
338                is_market: true,
339                tpsl,
340            }),
341            cloid: None,
342        }
343    }
344
345    /// Start building a trigger sell order (e.g. stop-loss or take-profit).
346    ///
347    /// Trigger orders fire as market orders when the trigger price is hit.
348    /// Defaults to `reduce_only = true`, no `cloid`.
349    pub fn trigger_sell(
350        asset: u32,
351        trigger_px: Decimal,
352        sz: Decimal,
353        tpsl: Tpsl,
354    ) -> OrderWireBuilder {
355        let trigger_px_str = trigger_px.normalize().to_string();
356        OrderWireBuilder {
357            asset,
358            is_buy: false,
359            limit_px: trigger_px_str.clone(),
360            sz: sz.normalize().to_string(),
361            reduce_only: true,
362            order_type: OrderTypeWire::Trigger(TriggerOrderType {
363                trigger_px: trigger_px_str,
364                is_market: true,
365                tpsl,
366            }),
367            cloid: None,
368        }
369    }
370}
371
372/// Wire format for order type — either a limit order or a trigger order.
373///
374/// Serializes to the Hyperliquid wire format:
375/// - Limit: `{"limit": {"tif": "Gtc"}}`
376/// - Trigger: `{"trigger": {"triggerPx": "...", "isMarket": true, "tpsl": "sl"}}`
377#[derive(Debug, Clone, PartialEq, Eq)]
378#[non_exhaustive]
379pub enum OrderTypeWire {
380    /// A limit order with time-in-force.
381    Limit(LimitOrderType),
382    /// A trigger (stop-loss / take-profit) order.
383    Trigger(TriggerOrderType),
384}
385
386impl OrderTypeWire {
387    /// Returns `true` if this is a limit order.
388    pub fn is_limit(&self) -> bool {
389        matches!(self, OrderTypeWire::Limit(_))
390    }
391
392    /// Returns `true` if this is a trigger order.
393    pub fn is_trigger(&self) -> bool {
394        matches!(self, OrderTypeWire::Trigger(_))
395    }
396}
397
398impl Serialize for OrderTypeWire {
399    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
400    where
401        S: Serializer,
402    {
403        let mut map = serializer.serialize_map(Some(1))?;
404        match self {
405            OrderTypeWire::Limit(limit) => {
406                map.serialize_entry("limit", limit)?;
407            }
408            OrderTypeWire::Trigger(trigger) => {
409                map.serialize_entry("trigger", trigger)?;
410            }
411        }
412        map.end()
413    }
414}
415
416impl<'de> Deserialize<'de> for OrderTypeWire {
417    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
418    where
419        D: Deserializer<'de>,
420    {
421        struct OrderTypeWireVisitor;
422
423        impl<'de> Visitor<'de> for OrderTypeWireVisitor {
424            type Value = OrderTypeWire;
425
426            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
427                formatter.write_str("a map with either a \"limit\" or \"trigger\" key")
428            }
429
430            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
431            where
432                A: MapAccess<'de>,
433            {
434                let key: String = map
435                    .next_key()?
436                    .ok_or_else(|| de::Error::custom("empty order type object"))?;
437                match key.as_str() {
438                    "limit" => {
439                        let limit: LimitOrderType = map.next_value()?;
440                        Ok(OrderTypeWire::Limit(limit))
441                    }
442                    "trigger" => {
443                        let trigger: TriggerOrderType = map.next_value()?;
444                        Ok(OrderTypeWire::Trigger(trigger))
445                    }
446                    other => Err(de::Error::unknown_field(other, &["limit", "trigger"])),
447                }
448            }
449        }
450
451        deserializer.deserialize_map(OrderTypeWireVisitor)
452    }
453}
454
455/// Limit order type wire format.
456#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
457pub struct LimitOrderType {
458    /// Time-in-force for the limit order.
459    pub tif: Tif,
460}
461
462/// Trigger order type wire format.
463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
464#[serde(rename_all = "camelCase")]
465pub struct TriggerOrderType {
466    /// Trigger price as a decimal string.
467    pub trigger_px: String,
468    /// Whether the triggered order executes as a market order.
469    pub is_market: bool,
470    /// Trigger direction (stop-loss or take-profit).
471    pub tpsl: Tpsl,
472}
473
474/// Request to cancel an order by asset index and server-assigned order ID.
475#[derive(Debug, Clone, PartialEq, Eq)]
476#[non_exhaustive]
477pub struct CancelRequest {
478    /// Asset index.
479    pub asset: u32,
480    /// Server-assigned order ID to cancel.
481    pub oid: u64,
482}
483
484impl CancelRequest {
485    /// Creates a new `CancelRequest`.
486    pub fn new(asset: u32, oid: u64) -> Self {
487        Self { asset, oid }
488    }
489}
490
491/// Request to cancel an order by asset index and client order ID.
492#[derive(Debug, Clone, PartialEq, Eq)]
493#[non_exhaustive]
494pub struct CancelByCloidRequest {
495    /// Asset index.
496    pub asset: u32,
497    /// Client order ID to cancel.
498    pub cloid: String,
499}
500
501impl CancelByCloidRequest {
502    /// Creates a new `CancelByCloidRequest`.
503    pub fn new(asset: u32, cloid: impl Into<String>) -> Self {
504        Self {
505            asset,
506            cloid: cloid.into(),
507        }
508    }
509}
510
511/// Request to amend an existing order in-place (atomic modification).
512#[derive(Debug, Clone, PartialEq, Eq)]
513#[non_exhaustive]
514pub struct ModifyRequest {
515    /// Server-assigned order ID to modify.
516    pub oid: u64,
517    /// Replacement order wire data.
518    pub order: OrderWire,
519}
520
521impl ModifyRequest {
522    /// Creates a new `ModifyRequest`.
523    pub fn new(oid: u64, order: OrderWire) -> Self {
524        Self { oid, order }
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use std::str::FromStr;
532
533    // ── OrderTypeWire enum serde ────────────────────────────────
534
535    #[test]
536    fn order_type_wire_limit_serialization() {
537        let ot = OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc });
538        let json = serde_json::to_string(&ot).unwrap();
539        assert_eq!(json, r#"{"limit":{"tif":"Gtc"}}"#);
540    }
541
542    #[test]
543    fn order_type_wire_trigger_serialization() {
544        let ot = OrderTypeWire::Trigger(TriggerOrderType {
545            trigger_px: "99.0".into(),
546            is_market: true,
547            tpsl: Tpsl::Sl,
548        });
549        let json = serde_json::to_string(&ot).unwrap();
550        assert_eq!(
551            json,
552            r#"{"trigger":{"triggerPx":"99.0","isMarket":true,"tpsl":"sl"}}"#
553        );
554    }
555
556    #[test]
557    fn order_type_wire_limit_roundtrip() {
558        let original = OrderTypeWire::Limit(LimitOrderType { tif: Tif::Ioc });
559        let json = serde_json::to_string(&original).unwrap();
560        let parsed: OrderTypeWire = serde_json::from_str(&json).unwrap();
561        assert_eq!(parsed, original);
562    }
563
564    #[test]
565    fn order_type_wire_trigger_roundtrip() {
566        let original = OrderTypeWire::Trigger(TriggerOrderType {
567            trigger_px: "50.5".into(),
568            is_market: false,
569            tpsl: Tpsl::Tp,
570        });
571        let json = serde_json::to_string(&original).unwrap();
572        let parsed: OrderTypeWire = serde_json::from_str(&json).unwrap();
573        assert_eq!(parsed, original);
574    }
575
576    #[test]
577    fn order_type_wire_is_limit_and_is_trigger() {
578        let limit = OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc });
579        assert!(limit.is_limit());
580        assert!(!limit.is_trigger());
581
582        let trigger = OrderTypeWire::Trigger(TriggerOrderType {
583            trigger_px: "1.0".into(),
584            is_market: true,
585            tpsl: Tpsl::Sl,
586        });
587        assert!(trigger.is_trigger());
588        assert!(!trigger.is_limit());
589    }
590
591    #[test]
592    fn order_type_wire_invalid_key_fails() {
593        let json = r#"{"unknown":{"tif":"Gtc"}}"#;
594        assert!(serde_json::from_str::<OrderTypeWire>(json).is_err());
595    }
596
597    #[test]
598    fn order_type_wire_empty_object_fails() {
599        let json = r#"{}"#;
600        assert!(serde_json::from_str::<OrderTypeWire>(json).is_err());
601    }
602
603    // ── OrderWire builder ───────────────────────────────────────
604
605    #[test]
606    fn builder_limit_buy_defaults() {
607        let order =
608            OrderWire::limit_buy(1, Decimal::from(90000), Decimal::from_str("0.001").unwrap())
609                .build()
610                .unwrap();
611        assert_eq!(order.asset, 1);
612        assert!(order.is_buy);
613        assert_eq!(order.limit_px, "90000");
614        assert_eq!(order.sz, "0.001");
615        assert!(!order.reduce_only);
616        assert!(order.order_type.is_limit());
617        assert!(order.cloid.is_none());
618        if let OrderTypeWire::Limit(ref l) = order.order_type {
619            assert_eq!(l.tif, Tif::Gtc);
620        }
621    }
622
623    #[test]
624    fn builder_limit_sell_with_options() {
625        let order = OrderWire::limit_sell(5, Decimal::from(3000), Decimal::from(2))
626            .tif(Tif::Ioc)
627            .cloid("my-order-1")
628            .reduce_only(true)
629            .build()
630            .unwrap();
631        assert_eq!(order.asset, 5);
632        assert!(!order.is_buy);
633        assert_eq!(order.limit_px, "3000");
634        assert_eq!(order.sz, "2");
635        assert!(order.reduce_only);
636        assert_eq!(order.cloid.as_deref(), Some("my-order-1"));
637        if let OrderTypeWire::Limit(ref l) = order.order_type {
638            assert_eq!(l.tif, Tif::Ioc);
639        } else {
640            panic!("expected limit order type");
641        }
642    }
643
644    #[test]
645    fn builder_trigger_buy() {
646        let order = OrderWire::trigger_buy(0, Decimal::from(99), Decimal::from(10), Tpsl::Sl)
647            .cloid("trigger-1")
648            .build()
649            .unwrap();
650        assert_eq!(order.asset, 0);
651        assert!(order.is_buy);
652        assert!(order.reduce_only);
653        assert!(order.order_type.is_trigger());
654        if let OrderTypeWire::Trigger(ref t) = order.order_type {
655            assert_eq!(t.trigger_px, "99");
656            assert!(t.is_market);
657            assert_eq!(t.tpsl, Tpsl::Sl);
658        } else {
659            panic!("expected trigger order type");
660        }
661    }
662
663    #[test]
664    fn builder_trigger_sell() {
665        let order = OrderWire::trigger_sell(2, Decimal::from(150), Decimal::from(5), Tpsl::Tp)
666            .reduce_only(false)
667            .build()
668            .unwrap();
669        assert_eq!(order.asset, 2);
670        assert!(!order.is_buy);
671        assert!(!order.reduce_only); // overridden from default true
672        assert!(order.order_type.is_trigger());
673        if let OrderTypeWire::Trigger(ref t) = order.order_type {
674            assert_eq!(t.trigger_px, "150");
675            assert_eq!(t.tpsl, Tpsl::Tp);
676        } else {
677            panic!("expected trigger order type");
678        }
679    }
680
681    #[test]
682    fn builder_tif_noop_on_trigger() {
683        // Calling .tif() on a trigger builder should not panic or change anything
684        let order = OrderWire::trigger_buy(0, Decimal::from(99), Decimal::ONE, Tpsl::Sl)
685            .tif(Tif::Ioc)
686            .build()
687            .unwrap();
688        assert!(order.order_type.is_trigger());
689    }
690
691    #[test]
692    fn build_validates_positive_price() {
693        let result = OrderWire::limit_buy(0, Decimal::ZERO, Decimal::ONE).build();
694        assert!(result.is_err());
695    }
696
697    #[test]
698    fn build_validates_positive_size() {
699        let result = OrderWire::limit_buy(0, Decimal::ONE, Decimal::ZERO).build();
700        assert!(result.is_err());
701    }
702
703    #[test]
704    fn build_validates_negative_price() {
705        let result = OrderWire::limit_buy(0, Decimal::from(-1), Decimal::ONE).build();
706        assert!(result.is_err());
707    }
708
709    #[test]
710    fn build_validates_negative_size() {
711        let result = OrderWire::limit_buy(0, Decimal::ONE, Decimal::from(-1)).build();
712        assert!(result.is_err());
713    }
714
715    #[test]
716    fn build_success() {
717        let result =
718            OrderWire::limit_buy(0, Decimal::from(90000), Decimal::from_str("0.001").unwrap())
719                .build();
720        assert!(result.is_ok());
721    }
722
723    #[test]
724    fn side_from_is_buy() {
725        assert_eq!(Side::from_is_buy(true), Side::Buy);
726        assert_eq!(Side::from_is_buy(false), Side::Sell);
727    }
728
729    // ── OrderWire serde (full struct) ───────────────────────────
730
731    #[test]
732    fn order_wire_limit_serde_roundtrip() {
733        let order =
734            OrderWire::limit_buy(1, Decimal::from(50000), Decimal::from_str("0.1").unwrap())
735                .cloid("test-cloid")
736                .build()
737                .unwrap();
738        let json = serde_json::to_string(&order).unwrap();
739        let parsed: OrderWire = serde_json::from_str(&json).unwrap();
740        assert_eq!(parsed.asset, 1);
741        assert!(parsed.is_buy);
742        assert_eq!(parsed.limit_px, "50000");
743        assert_eq!(parsed.sz, "0.1");
744        assert!(!parsed.reduce_only);
745        assert_eq!(parsed.cloid.as_deref(), Some("test-cloid"));
746        assert!(parsed.order_type.is_limit());
747    }
748
749    #[test]
750    fn order_wire_trigger_serde_roundtrip() {
751        let order = OrderWire::trigger_buy(0, Decimal::from(100), Decimal::from(10), Tpsl::Tp)
752            .build()
753            .unwrap();
754        let json = serde_json::to_string(&order).unwrap();
755        let parsed: OrderWire = serde_json::from_str(&json).unwrap();
756        let trigger = match parsed.order_type {
757            OrderTypeWire::Trigger(t) => t,
758            _ => panic!("expected trigger"),
759        };
760        assert_eq!(trigger.trigger_px, "100");
761        assert!(trigger.is_market);
762        assert_eq!(trigger.tpsl, Tpsl::Tp);
763    }
764
765    #[test]
766    fn order_wire_camel_case_serialization() {
767        let order = OrderWire::limit_buy(0, Decimal::ONE, Decimal::ONE)
768            .build()
769            .unwrap();
770        let json = serde_json::to_string(&order).unwrap();
771        assert!(json.contains("isBuy"));
772        assert!(json.contains("limitPx"));
773        assert!(json.contains("reduceOnly"));
774        assert!(json.contains("orderType"));
775        // cloid is None and skip_serializing_if, so should not appear
776        assert!(!json.contains("cloid"));
777    }
778
779    #[test]
780    fn order_wire_with_cloid_roundtrip() {
781        let order =
782            OrderWire::limit_sell(5, Decimal::from_str("3000.5").unwrap(), Decimal::from(2))
783                .reduce_only(true)
784                .cloid("my-order-123")
785                .build()
786                .unwrap();
787        let json = serde_json::to_string(&order).unwrap();
788        let parsed: OrderWire = serde_json::from_str(&json).unwrap();
789        assert_eq!(parsed.cloid.as_deref(), Some("my-order-123"));
790        assert!(parsed.reduce_only);
791        assert!(!parsed.is_buy);
792    }
793
794    // ── Wire format backward compatibility ──────────────────────
795
796    #[test]
797    fn wire_format_limit_matches_hyperliquid() {
798        // Hyperliquid expects: {"limit": {"tif": "Gtc"}}
799        let ot = OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc });
800        let json = serde_json::to_value(&ot).unwrap();
801        assert!(json.get("limit").is_some());
802        assert_eq!(json["limit"]["tif"], "Gtc");
803    }
804
805    #[test]
806    fn wire_format_trigger_matches_hyperliquid() {
807        // Hyperliquid expects: {"trigger": {"triggerPx": "...", "isMarket": ..., "tpsl": "..."}}
808        let ot = OrderTypeWire::Trigger(TriggerOrderType {
809            trigger_px: "99.0".into(),
810            is_market: true,
811            tpsl: Tpsl::Sl,
812        });
813        let json = serde_json::to_value(&ot).unwrap();
814        assert!(json.get("trigger").is_some());
815        assert_eq!(json["trigger"]["triggerPx"], "99.0");
816        assert_eq!(json["trigger"]["isMarket"], true);
817        assert_eq!(json["trigger"]["tpsl"], "sl");
818    }
819
820    #[test]
821    fn deserialize_from_hyperliquid_limit_json() {
822        // Simulate what Hyperliquid would send back
823        let json = r#"{"limit":{"tif":"Gtc"}}"#;
824        let ot: OrderTypeWire = serde_json::from_str(json).unwrap();
825        assert_eq!(ot, OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc }));
826    }
827
828    #[test]
829    fn deserialize_from_hyperliquid_trigger_json() {
830        let json = r#"{"trigger":{"triggerPx":"99.0","isMarket":true,"tpsl":"sl"}}"#;
831        let ot: OrderTypeWire = serde_json::from_str(json).unwrap();
832        assert_eq!(
833            ot,
834            OrderTypeWire::Trigger(TriggerOrderType {
835                trigger_px: "99.0".into(),
836                is_market: true,
837                tpsl: Tpsl::Sl,
838            })
839        );
840    }
841
842    // ── Existing enum serde tests (preserved) ───────────────────
843
844    #[test]
845    fn tif_serde_wire_format() {
846        assert_eq!(serde_json::to_string(&Tif::Gtc).unwrap(), "\"Gtc\"");
847        assert_eq!(serde_json::to_string(&Tif::Ioc).unwrap(), "\"Ioc\"");
848        assert_eq!(serde_json::to_string(&Tif::Alo).unwrap(), "\"Alo\"");
849
850        assert_eq!(serde_json::from_str::<Tif>("\"Gtc\"").unwrap(), Tif::Gtc);
851        assert_eq!(serde_json::from_str::<Tif>("\"Ioc\"").unwrap(), Tif::Ioc);
852        assert_eq!(serde_json::from_str::<Tif>("\"Alo\"").unwrap(), Tif::Alo);
853    }
854
855    #[test]
856    fn tpsl_serde_wire_format() {
857        assert_eq!(serde_json::to_string(&Tpsl::Sl).unwrap(), "\"sl\"");
858        assert_eq!(serde_json::to_string(&Tpsl::Tp).unwrap(), "\"tp\"");
859
860        assert_eq!(serde_json::from_str::<Tpsl>("\"sl\"").unwrap(), Tpsl::Sl);
861        assert_eq!(serde_json::from_str::<Tpsl>("\"tp\"").unwrap(), Tpsl::Tp);
862    }
863
864    #[test]
865    fn side_serde_wire_format() {
866        assert_eq!(serde_json::to_string(&Side::Buy).unwrap(), "\"buy\"");
867        assert_eq!(serde_json::to_string(&Side::Sell).unwrap(), "\"sell\"");
868
869        assert_eq!(serde_json::from_str::<Side>("\"buy\"").unwrap(), Side::Buy);
870        assert_eq!(
871            serde_json::from_str::<Side>("\"sell\"").unwrap(),
872            Side::Sell
873        );
874    }
875
876    #[test]
877    fn side_is_buy() {
878        assert!(Side::Buy.is_buy());
879        assert!(!Side::Sell.is_buy());
880    }
881
882    #[test]
883    fn position_side_serde_wire_format() {
884        assert_eq!(
885            serde_json::to_string(&PositionSide::Long).unwrap(),
886            "\"long\""
887        );
888        assert_eq!(
889            serde_json::to_string(&PositionSide::Short).unwrap(),
890            "\"short\""
891        );
892
893        assert_eq!(
894            serde_json::from_str::<PositionSide>("\"long\"").unwrap(),
895            PositionSide::Long
896        );
897        assert_eq!(
898            serde_json::from_str::<PositionSide>("\"short\"").unwrap(),
899            PositionSide::Short
900        );
901    }
902
903    #[test]
904    fn order_status_serde_wire_format() {
905        assert_eq!(
906            serde_json::to_string(&OrderStatus::Filled).unwrap(),
907            "\"filled\""
908        );
909        assert_eq!(
910            serde_json::to_string(&OrderStatus::Partial).unwrap(),
911            "\"partial\""
912        );
913        assert_eq!(
914            serde_json::to_string(&OrderStatus::Open).unwrap(),
915            "\"open\""
916        );
917        assert_eq!(
918            serde_json::to_string(&OrderStatus::TriggerSl).unwrap(),
919            "\"trigger_sl\""
920        );
921        assert_eq!(
922            serde_json::to_string(&OrderStatus::TriggerTp).unwrap(),
923            "\"trigger_tp\""
924        );
925
926        assert_eq!(
927            serde_json::from_str::<OrderStatus>("\"filled\"").unwrap(),
928            OrderStatus::Filled
929        );
930        assert_eq!(
931            serde_json::from_str::<OrderStatus>("\"trigger_sl\"").unwrap(),
932            OrderStatus::TriggerSl
933        );
934    }
935
936    #[test]
937    fn display_impls() {
938        assert_eq!(Side::Buy.to_string(), "buy");
939        assert_eq!(Side::Sell.to_string(), "sell");
940        assert_eq!(Tif::Gtc.to_string(), "Gtc");
941        assert_eq!(Tpsl::Sl.to_string(), "sl");
942        assert_eq!(Tpsl::Tp.to_string(), "tp");
943        assert_eq!(PositionSide::Long.to_string(), "long");
944        assert_eq!(PositionSide::Short.to_string(), "short");
945        assert_eq!(OrderStatus::Filled.to_string(), "filled");
946        assert_eq!(OrderStatus::TriggerSl.to_string(), "trigger_sl");
947    }
948
949    #[test]
950    fn invalid_side_deserialization_fails() {
951        assert!(serde_json::from_str::<Side>("\"BUY\"").is_err());
952        assert!(serde_json::from_str::<Side>("\"Buy\"").is_err());
953    }
954
955    #[test]
956    fn invalid_tif_deserialization_fails() {
957        assert!(serde_json::from_str::<Tif>("\"gtc\"").is_err());
958        assert!(serde_json::from_str::<Tif>("\"GTC\"").is_err());
959    }
960
961    #[test]
962    fn grouping_as_str_matches_wire() {
963        assert_eq!(Grouping::Na.as_str(), "na");
964        assert_eq!(Grouping::NormalTpsl.as_str(), "normalTpsl");
965        assert_eq!(Grouping::PositionTpsl.as_str(), "positionTpsl");
966        assert_eq!(Grouping::default(), Grouping::Na);
967        assert_eq!(Grouping::NormalTpsl.to_string(), "normalTpsl");
968    }
969}