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