Skip to main content

hyperliquid_sdk/
order.rs

1//! Order builders for the Hyperliquid SDK.
2//!
3//! Provides fluent API for building orders:
4//!
5//! ```rust,ignore
6//! use hyperliquid_sdk::Order;
7//!
8//! // Simple limit order
9//! let order = Order::buy("BTC").size(0.001).price(65000.0).gtc();
10//!
11//! // Market order with notional
12//! let order = Order::sell("ETH").notional(500.0).market();
13//!
14//! // Stop loss
15//! let trigger = TriggerOrder::stop_loss("BTC").size(0.001).trigger_price(60000.0).market();
16//! ```
17
18use alloy::primitives::B128;
19use rust_decimal::Decimal;
20use std::str::FromStr;
21use std::sync::Arc;
22
23use crate::types::{
24    Cloid, OrderRequest, OrderTypePlacement, Side, TIF, TimeInForce, TpSl,
25};
26
27// ══════════════════════════════════════════════════════════════════════════════
28// Order Builder
29// ══════════════════════════════════════════════════════════════════════════════
30
31/// Fluent order builder
32#[derive(Debug, Clone)]
33pub struct Order {
34    asset: String,
35    side: Side,
36    size: Option<Decimal>,
37    notional: Option<Decimal>,
38    price: Option<Decimal>,
39    tif: TIF,
40    reduce_only: bool,
41    cloid: Option<Cloid>,
42    priority_fee: Option<u64>,
43}
44
45impl Order {
46    /// Create a buy order
47    pub fn buy(asset: impl Into<String>) -> Self {
48        Self::new(asset.into(), Side::Buy)
49    }
50
51    /// Create a sell order
52    pub fn sell(asset: impl Into<String>) -> Self {
53        Self::new(asset.into(), Side::Sell)
54    }
55
56    /// Alias for buy (for perps)
57    pub fn long(asset: impl Into<String>) -> Self {
58        Self::buy(asset)
59    }
60
61    /// Alias for sell (for perps)
62    pub fn short(asset: impl Into<String>) -> Self {
63        Self::sell(asset)
64    }
65
66    fn new(asset: String, side: Side) -> Self {
67        Self {
68            asset,
69            side,
70            size: None,
71            notional: None,
72            price: None,
73            tif: TIF::Ioc,
74            reduce_only: false,
75            cloid: None,
76            priority_fee: None,
77        }
78    }
79
80    /// Set the order size (in base asset units)
81    pub fn size(mut self, size: f64) -> Self {
82        self.size = Some(Decimal::from_f64_retain(size).unwrap_or_default());
83        self
84    }
85
86    /// Set the order size from a Decimal
87    pub fn size_decimal(mut self, size: Decimal) -> Self {
88        self.size = Some(size);
89        self
90    }
91
92    /// Set the notional value (in USD)
93    pub fn notional(mut self, notional: f64) -> Self {
94        self.notional = Some(Decimal::from_f64_retain(notional).unwrap_or_default());
95        self
96    }
97
98    /// Set the limit price
99    pub fn price(mut self, price: f64) -> Self {
100        self.price = Some(Decimal::from_f64_retain(price).unwrap_or_default());
101        self
102    }
103
104    /// Alias for price
105    pub fn limit(self, price: f64) -> Self {
106        self.price(price)
107    }
108
109    /// Set as market order (IOC with slippage applied by SDK)
110    pub fn market(mut self) -> Self {
111        self.tif = TIF::Market;
112        self
113    }
114
115    /// Set as Immediate-or-Cancel
116    pub fn ioc(mut self) -> Self {
117        self.tif = TIF::Ioc;
118        self
119    }
120
121    /// Set as Good-Till-Cancel
122    pub fn gtc(mut self) -> Self {
123        self.tif = TIF::Gtc;
124        self
125    }
126
127    /// Set as Add-Liquidity-Only (post-only)
128    pub fn alo(mut self) -> Self {
129        self.tif = TIF::Alo;
130        self
131    }
132
133    /// Alias for alo
134    pub fn post_only(self) -> Self {
135        self.alo()
136    }
137
138    /// Set as reduce-only
139    pub fn reduce_only(mut self) -> Self {
140        self.reduce_only = true;
141        self
142    }
143
144    /// Set a client order ID
145    pub fn cloid(mut self, cloid: impl Into<String>) -> Self {
146        let cloid_str = cloid.into();
147        if let Ok(parsed) = cloid_str.parse::<B128>() {
148            self.cloid = Some(parsed);
149        }
150        self
151    }
152
153    /// Set a client order ID from bytes
154    pub fn cloid_bytes(mut self, cloid: [u8; 16]) -> Self {
155        self.cloid = Some(B128::from(cloid));
156        self
157    }
158
159    /// Generate a random client order ID
160    pub fn random_cloid(mut self) -> Self {
161        let mut bytes = [0u8; 16];
162        // Simple random using time
163        let now = std::time::SystemTime::now()
164            .duration_since(std::time::UNIX_EPOCH)
165            .unwrap_or_default();
166        let nanos = now.as_nanos() as u64;
167        bytes[0..8].copy_from_slice(&nanos.to_le_bytes());
168        bytes[8..16].copy_from_slice(&(nanos.wrapping_mul(0x517cc1b727220a95)).to_le_bytes());
169        self.cloid = Some(B128::from(bytes));
170        self
171    }
172
173    /// Set Hyperliquid order priority fee rate.
174    ///
175    /// Hyperliquid interprets p as p / 100000000 of filled notional.
176    /// p=10000 is 1 bp. Fees are paid from undelegated staking HYPE.
177    pub fn priority_fee(mut self, p: u64) -> Self {
178        self.priority_fee = Some(p);
179        self
180    }
181
182    // ──────────────────────────────────────────────────────────────────────────
183    // Getters
184    // ──────────────────────────────────────────────────────────────────────────
185
186    /// Get the asset
187    pub fn get_asset(&self) -> &str {
188        &self.asset
189    }
190
191    /// Get the side
192    pub fn get_side(&self) -> Side {
193        self.side
194    }
195
196    /// Get the size (if set)
197    pub fn get_size(&self) -> Option<Decimal> {
198        self.size
199    }
200
201    /// Get the notional (if set)
202    pub fn get_notional(&self) -> Option<Decimal> {
203        self.notional
204    }
205
206    /// Get the price (if set)
207    pub fn get_price(&self) -> Option<Decimal> {
208        self.price
209    }
210
211    /// Get the time-in-force
212    pub fn get_tif(&self) -> TIF {
213        self.tif
214    }
215
216    /// Is this a reduce-only order?
217    pub fn is_reduce_only(&self) -> bool {
218        self.reduce_only
219    }
220
221    /// Is this a market order?
222    pub fn is_market(&self) -> bool {
223        self.tif == TIF::Market || self.price.is_none()
224    }
225
226    /// Get the client order ID (if set)
227    pub fn get_cloid(&self) -> Option<Cloid> {
228        self.cloid
229    }
230
231    /// Get the order priority fee if set
232    pub fn get_priority_fee(&self) -> Option<u64> {
233        self.priority_fee
234    }
235
236    // ──────────────────────────────────────────────────────────────────────────
237    // Conversion
238    // ──────────────────────────────────────────────────────────────────────────
239
240    /// Validate the order
241    pub fn validate(&self) -> crate::Result<()> {
242        if self.size.is_none() && self.notional.is_none() {
243            return Err(crate::Error::ValidationError(
244                "Order must have either size or notional".to_string(),
245            ));
246        }
247
248        if self.tif != TIF::Market && self.price.is_none() && self.notional.is_none() {
249            return Err(crate::Error::ValidationError(
250                "Non-market orders must have a price".to_string(),
251            ));
252        }
253
254        Ok(())
255    }
256
257    /// Convert to an OrderRequest (requires asset index resolution)
258    pub fn to_request(&self, asset_index: usize, resolved_price: Decimal) -> OrderRequest {
259        // Generate random cloid if not set (Hyperliquid requires nonzero cloid)
260        let cloid = self.cloid.unwrap_or_else(|| {
261            let mut bytes = [0u8; 16];
262            let now = std::time::SystemTime::now()
263                .duration_since(std::time::UNIX_EPOCH)
264                .unwrap_or_default();
265            let nanos = now.as_nanos() as u64;
266            bytes[0..8].copy_from_slice(&nanos.to_le_bytes());
267            bytes[8..16].copy_from_slice(&(nanos.wrapping_mul(0x517cc1b727220a95)).to_le_bytes());
268            B128::from(bytes)
269        });
270
271        OrderRequest {
272            asset: asset_index,
273            is_buy: self.side.is_buy(),
274            limit_px: resolved_price,
275            sz: self.size.unwrap_or_default(),
276            reduce_only: self.reduce_only,
277            order_type: OrderTypePlacement::Limit {
278                tif: TimeInForce::from(self.tif),
279            },
280            cloid,
281        }
282    }
283}
284
285// ══════════════════════════════════════════════════════════════════════════════
286// Trigger Order Builder
287// ══════════════════════════════════════════════════════════════════════════════
288
289/// Fluent trigger order builder (stop-loss / take-profit)
290#[derive(Debug, Clone)]
291pub struct TriggerOrder {
292    asset: String,
293    tpsl: TpSl,
294    side: Side,
295    size: Option<Decimal>,
296    trigger_price: Option<Decimal>,
297    limit_price: Option<Decimal>,
298    is_market: bool,
299    reduce_only: bool,
300    cloid: Option<Cloid>,
301}
302
303impl TriggerOrder {
304    /// Create a stop-loss order
305    pub fn stop_loss(asset: impl Into<String>) -> Self {
306        Self::new(asset.into(), TpSl::Sl)
307    }
308
309    /// Alias for stop_loss
310    pub fn sl(asset: impl Into<String>) -> Self {
311        Self::stop_loss(asset)
312    }
313
314    /// Create a take-profit order
315    pub fn take_profit(asset: impl Into<String>) -> Self {
316        Self::new(asset.into(), TpSl::Tp)
317    }
318
319    /// Alias for take_profit
320    pub fn tp(asset: impl Into<String>) -> Self {
321        Self::take_profit(asset)
322    }
323
324    fn new(asset: String, tpsl: TpSl) -> Self {
325        Self {
326            asset,
327            tpsl,
328            side: Side::Sell, // Default to sell (closing a long)
329            size: None,
330            trigger_price: None,
331            limit_price: None,
332            is_market: true,
333            reduce_only: true, // Default to reduce-only
334            cloid: None,
335        }
336    }
337
338    /// Set the order side
339    pub fn side(mut self, side: Side) -> Self {
340        self.side = side;
341        self
342    }
343
344    /// Set to buy side
345    pub fn buy(mut self) -> Self {
346        self.side = Side::Buy;
347        self
348    }
349
350    /// Set to sell side
351    pub fn sell(mut self) -> Self {
352        self.side = Side::Sell;
353        self
354    }
355
356    /// Set the order size
357    pub fn size(mut self, size: f64) -> Self {
358        self.size = Some(Decimal::from_f64_retain(size).unwrap_or_default());
359        self
360    }
361
362    /// Set the trigger price
363    pub fn trigger_price(mut self, price: f64) -> Self {
364        self.trigger_price = Some(Decimal::from_f64_retain(price).unwrap_or_default());
365        self
366    }
367
368    /// Alias for trigger_price
369    pub fn trigger(self, price: f64) -> Self {
370        self.trigger_price(price)
371    }
372
373    /// Set as market execution (when triggered)
374    pub fn market(mut self) -> Self {
375        self.is_market = true;
376        self.limit_price = None;
377        self
378    }
379
380    /// Set limit price (when triggered)
381    pub fn limit(mut self, price: f64) -> Self {
382        self.is_market = false;
383        self.limit_price = Some(Decimal::from_f64_retain(price).unwrap_or_default());
384        self
385    }
386
387    /// Set as reduce-only
388    pub fn reduce_only(mut self) -> Self {
389        self.reduce_only = true;
390        self
391    }
392
393    /// Allow increasing position
394    pub fn not_reduce_only(mut self) -> Self {
395        self.reduce_only = false;
396        self
397    }
398
399    /// Set a client order ID
400    pub fn cloid(mut self, cloid: impl Into<String>) -> Self {
401        let cloid_str = cloid.into();
402        if let Ok(parsed) = cloid_str.parse::<B128>() {
403            self.cloid = Some(parsed);
404        }
405        self
406    }
407
408    // ──────────────────────────────────────────────────────────────────────────
409    // Getters
410    // ──────────────────────────────────────────────────────────────────────────
411
412    /// Get the asset
413    pub fn get_asset(&self) -> &str {
414        &self.asset
415    }
416
417    /// Get the trigger type
418    pub fn get_tpsl(&self) -> TpSl {
419        self.tpsl
420    }
421
422    /// Get the side
423    pub fn get_side(&self) -> Side {
424        self.side
425    }
426
427    /// Get the size
428    pub fn get_size(&self) -> Option<Decimal> {
429        self.size
430    }
431
432    /// Get the trigger price
433    pub fn get_trigger_price(&self) -> Option<Decimal> {
434        self.trigger_price
435    }
436
437    /// Get the limit price
438    pub fn get_limit_price(&self) -> Option<Decimal> {
439        self.limit_price
440    }
441
442    /// Is market execution?
443    pub fn is_market(&self) -> bool {
444        self.is_market
445    }
446
447    /// Is reduce-only?
448    pub fn is_reduce_only(&self) -> bool {
449        self.reduce_only
450    }
451
452    // ──────────────────────────────────────────────────────────────────────────
453    // Validation
454    // ──────────────────────────────────────────────────────────────────────────
455
456    /// Validate the trigger order
457    pub fn validate(&self) -> crate::Result<()> {
458        if self.size.is_none() {
459            return Err(crate::Error::ValidationError(
460                "Trigger order must have a size".to_string(),
461            ));
462        }
463
464        if self.trigger_price.is_none() {
465            return Err(crate::Error::ValidationError(
466                "Trigger order must have a trigger price".to_string(),
467            ));
468        }
469
470        Ok(())
471    }
472
473    /// Convert to an OrderRequest
474    pub fn to_request(&self, asset_index: usize, execution_price: Decimal) -> OrderRequest {
475        OrderRequest {
476            asset: asset_index,
477            is_buy: self.side.is_buy(),
478            limit_px: self.limit_price.unwrap_or(execution_price),
479            sz: self.size.unwrap_or_default(),
480            reduce_only: self.reduce_only,
481            order_type: OrderTypePlacement::Trigger {
482                is_market: self.is_market,
483                trigger_px: self.trigger_price.unwrap_or_default(),
484                tpsl: self.tpsl,
485            },
486            cloid: self.cloid.unwrap_or(B128::ZERO),
487        }
488    }
489}
490
491// ══════════════════════════════════════════════════════════════════════════════
492// Placed Order
493// ══════════════════════════════════════════════════════════════════════════════
494
495/// A placed order with methods for cancellation and modification.
496#[derive(Debug, Clone)]
497pub struct PlacedOrder {
498    /// Order ID (None if order failed)
499    pub oid: Option<u64>,
500    /// Order status
501    pub status: String,
502    /// Asset name
503    pub asset: String,
504    /// Order side
505    pub side: String,
506    /// Order size
507    pub size: String,
508    /// Limit price (if applicable)
509    pub price: Option<String>,
510    /// Filled size
511    pub filled_size: Option<String>,
512    /// Average fill price
513    pub avg_price: Option<String>,
514    /// Error message (if failed)
515    pub error: Option<String>,
516    /// Raw response from the API
517    pub raw_response: serde_json::Value,
518    /// Reference to SDK for cancel/modify operations
519    sdk: Option<Arc<crate::client::HyperliquidSDKInner>>,
520}
521
522impl PlacedOrder {
523    /// Create from API response
524    pub(crate) fn from_response(
525        response: serde_json::Value,
526        asset: String,
527        side: Side,
528        size: Decimal,
529        price: Option<Decimal>,
530        sdk: Option<Arc<crate::client::HyperliquidSDKInner>>,
531    ) -> Self {
532        // Parse response to extract order details
533        let status = response
534            .get("status")
535            .and_then(|s| s.as_str())
536            .unwrap_or("unknown")
537            .to_string();
538
539        let mut oid = None;
540        let mut filled_size = None;
541        let mut avg_price = None;
542        let mut error = None;
543
544        if status == "ok" {
545            // Extract from response.response.data.statuses[0]
546            if let Some(data) = response
547                .get("response")
548                .and_then(|r| r.get("data"))
549                .and_then(|d| d.get("statuses"))
550                .and_then(|s| s.get(0))
551            {
552                // Check for "resting" status
553                if let Some(resting) = data.get("resting") {
554                    oid = resting.get("oid").and_then(|o| o.as_u64());
555                }
556                // Check for "filled" status
557                if let Some(filled) = data.get("filled") {
558                    oid = filled.get("oid").and_then(|o| o.as_u64());
559                    filled_size = filled
560                        .get("totalSz")
561                        .and_then(|s| s.as_str())
562                        .map(|s| s.to_string());
563                    avg_price = filled
564                        .get("avgPx")
565                        .and_then(|p| p.as_str())
566                        .map(|s| s.to_string());
567                }
568                // Check for error
569                if let Some(err) = data.get("error") {
570                    error = err.as_str().map(|s| s.to_string());
571                }
572            }
573        } else if status == "err" {
574            error = response
575                .get("response")
576                .and_then(|r| r.as_str())
577                .map(|s| s.to_string());
578        }
579
580        let status_str = if oid.is_some() {
581            if filled_size.is_some() {
582                "filled"
583            } else {
584                "resting"
585            }
586        } else if error.is_some() {
587            "error"
588        } else {
589            "unknown"
590        };
591
592        Self {
593            oid,
594            status: status_str.to_string(),
595            asset,
596            side: side.to_string(),
597            size: size.to_string(),
598            price: price.map(|p| p.to_string()),
599            filled_size,
600            avg_price,
601            error,
602            raw_response: response,
603            sdk,
604        }
605    }
606
607    /// Create an error response
608    #[allow(dead_code)]
609    pub(crate) fn error(
610        asset: String,
611        side: Side,
612        size: Decimal,
613        error: String,
614    ) -> Self {
615        Self {
616            oid: None,
617            status: "error".to_string(),
618            asset,
619            side: side.to_string(),
620            size: size.to_string(),
621            price: None,
622            filled_size: None,
623            avg_price: None,
624            error: Some(error),
625            raw_response: serde_json::Value::Null,
626            sdk: None,
627        }
628    }
629
630    /// Is the order resting on the book?
631    pub fn is_resting(&self) -> bool {
632        self.status == "resting"
633    }
634
635    /// Is the order fully filled?
636    pub fn is_filled(&self) -> bool {
637        self.status == "filled"
638    }
639
640    /// Did the order fail?
641    pub fn is_error(&self) -> bool {
642        self.status == "error" || self.error.is_some()
643    }
644
645    /// Cancel this order
646    pub async fn cancel(&self) -> crate::Result<serde_json::Value> {
647        let oid = self.oid.ok_or_else(|| {
648            crate::Error::OrderError("Cannot cancel order without OID".to_string())
649        })?;
650
651        let sdk = self.sdk.as_ref().ok_or_else(|| {
652            crate::Error::OrderError("SDK reference not available for cancel".to_string())
653        })?;
654
655        sdk.cancel_by_oid(oid, &self.asset).await
656    }
657
658    /// Modify this order
659    pub async fn modify(
660        &self,
661        price: Option<f64>,
662        size: Option<f64>,
663    ) -> crate::Result<PlacedOrder> {
664        let oid = self.oid.ok_or_else(|| {
665            crate::Error::OrderError("Cannot modify order without OID".to_string())
666        })?;
667
668        let sdk = self.sdk.as_ref().ok_or_else(|| {
669            crate::Error::OrderError("SDK reference not available for modify".to_string())
670        })?;
671
672        let new_price = price
673            .map(|p| Decimal::from_f64_retain(p).unwrap_or_default())
674            .or_else(|| self.price.as_ref().and_then(|p| Decimal::from_str(p).ok()));
675
676        let new_size = size
677            .map(|s| Decimal::from_f64_retain(s).unwrap_or_default())
678            .or_else(|| Decimal::from_str(&self.size).ok());
679
680        sdk.modify_by_oid(
681            oid,
682            &self.asset,
683            Side::from_str(&self.side).unwrap_or(Side::Buy),
684            new_price.unwrap_or_default(),
685            new_size.unwrap_or_default(),
686        )
687        .await
688    }
689}