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