Skip to main content

perpl_sdk/state/
order.rs

1use std::num::NonZeroU16;
2
3use alloy::primitives::U256;
4use fastnum::UD64;
5use thiserror::Error;
6
7use super::{event, types};
8use crate::{abi::dex, num};
9
10/// Error creating an Order from exchange data.
11#[derive(Debug, Clone, Error)]
12pub enum OrderParseError {
13    /// Order has invalid ID 0 (which is reserved as NULL_ORDER_ID on the
14    /// exchange).
15    #[error("order has invalid id 0")]
16    ZeroOrderId,
17}
18
19/// Active order in the perpetual contract order book.
20///
21/// Exchange order book has a limited capacity of 2^16-1 orders, which requires
22/// an extensive reuse of order IDs, up to the point that within the order of
23/// execution of a single order request, the same order ID can be used for more
24/// than one order. For example, if a taker order partially matches and then
25/// gets placed, the matched maker order with order ID = 1 gets removed from the
26/// book (and thus vacates the ID), then taker order gets placed under the same
27/// order ID = 1.
28///
29/// So the state of order book and particular mapping between orders and their
30/// IDs is tied to a particular point in time and should be used with care.
31///
32/// Exchange does not support concept of client order IDs and does not store any
33/// externally-provided state with orders on-chain, but each order request emits
34/// provided [`Order::request_id()`] with it, which gets indexed and stored with
35/// the order, with the original request ID preserved as
36/// [`Order::client_order_id()`] but with the limitation that this data is
37/// available only from events, not from the original snapshot.
38///
39/// See [`crate::abi::dex::Exchange::OrderDesc`] for more details on particular
40/// order parameters and exchange behavior.
41/// This wrapper provides automatic conversion from exchnage fixed numeric types
42/// to decimal numbers.
43#[derive(Clone, Copy, derive_more::Debug)]
44pub struct Order {
45    instant: types::StateInstant,
46    request_id: Option<types::RequestId>,
47    client_order_id: Option<types::RequestId>,
48    order_id: types::OrderId,
49    r#type: types::OrderType,
50    account_id: types::AccountId,
51    #[debug("{price}")]
52    price: UD64, // SC allocates 24 bits + base price
53    #[debug("{size}")]
54    size: UD64, // SC allocates 40 bits
55    placed_size: Option<UD64>, // SC allocates 40 bits
56    expiry_block: u64,
57    #[debug("{leverage}")]
58    leverage: UD64,
59    post_only: Option<bool>,
60    fill_or_kill: Option<bool>,
61    immediate_or_cancel: Option<bool>,
62    // Builder the order is attributed to, with the additive fee rate it charges.
63    // None means no builder, which is also the only possibility on contracts
64    // without builder attribution.
65    builder: Option<types::BuilderAttribution>,
66    // Linked list pointers for FIFO ordering at each price level.
67    // Available from snapshot, None for newly placed orders (until refreshed).
68    prev_order_id: Option<types::OrderId>,
69    next_order_id: Option<types::OrderId>,
70}
71
72impl Order {
73    pub(crate) fn from_snapshot(
74        instant: types::StateInstant,
75        order: dex::Exchange::OrderV2,
76        base_price: UD64,
77        price_converter: num::Converter,
78        size_converter: num::Converter,
79        leverage_converter: num::Converter,
80    ) -> Result<Self, OrderParseError> {
81        // Exchange uses 0 as NULL_ORDER_ID - a valid order must have non-zero ID
82        let order_id = NonZeroU16::new(order.orderId).ok_or(OrderParseError::ZeroOrderId)?;
83
84        // Convert 0 to None for linked list pointers (0 means no link)
85        // Since we checked orderId != 0 above, NonZeroU16::new() here is safe
86        let prev_order_id = NonZeroU16::new(order.prevOrderId);
87        let next_order_id = NonZeroU16::new(order.nextOrderId);
88
89        Ok(Self {
90            instant,
91            request_id: None,
92            client_order_id: None, // Not available from snapshot
93            order_id,
94            r#type: order.orderType.into(),
95            account_id: order.accountId,
96            price: base_price + price_converter.from_unsigned(order.priceONS.to()),
97            size: size_converter.from_unsigned(order.lotLNS.to()),
98            placed_size: None,
99            expiry_block: order.expiryBlock as u64,
100            leverage: leverage_converter.from_u64(order.leverageHdths as u64),
101            post_only: None,
102            fill_or_kill: None,
103            immediate_or_cancel: None,
104            // Unlike the flags above, builder attribution IS persisted with the
105            // resting order, so the snapshot recovers it in full
106            builder: (order.builderId != 0).then(|| {
107                types::BuilderAttribution::from_raw(
108                    order.builderId,
109                    U256::from(order.builderFeePer100K),
110                )
111            }),
112            prev_order_id,
113            next_order_id,
114        })
115    }
116
117    pub(crate) fn placed(
118        instant: types::StateInstant,
119        ctx: &event::OrderContext,
120        order_id: types::OrderId,
121        size: UD64,
122        price_converter: num::Converter,
123        leverage_converter: num::Converter,
124    ) -> Self {
125        Self {
126            instant,
127            request_id: Some(ctx.request_id),
128            // Original [`request_id`] becomes [`client_order_id`]
129            client_order_id: Some(ctx.request_id),
130            order_id,
131            r#type: ctx.r#type.into(),
132            account_id: ctx.account_id,
133            price: price_converter.from_unsigned(ctx.price),
134            size,
135            placed_size: Some(size),
136            expiry_block: ctx.expiry_block,
137            leverage: leverage_converter.from_unsigned(ctx.leverage),
138            post_only: Some(ctx.post_only),
139            fill_or_kill: Some(ctx.fill_or_kill),
140            immediate_or_cancel: Some(ctx.immediate_or_cancel),
141            builder: ctx.builder,
142            // New orders don't have linked list info from events
143            prev_order_id: None,
144            next_order_id: None,
145        }
146    }
147
148    pub(crate) fn updated(
149        &self,
150        instant: types::StateInstant,
151        ctx: &Option<event::OrderContext>,
152        price: Option<UD64>,
153        size: Option<UD64>,
154        placed_size: Option<UD64>,
155        expiry_block: Option<u64>,
156    ) -> Self {
157        Self {
158            instant,
159            request_id: ctx.as_ref().map(|c| c.request_id),
160            // Original [`client_order_id`] is preserved
161            client_order_id: self.client_order_id,
162            order_id: self.order_id,
163            r#type: self.r#type,
164            account_id: self.account_id,
165            price: price.unwrap_or(self.price),
166            size: size.unwrap_or(self.size),
167            placed_size: placed_size.or(self.placed_size),
168            expiry_block: expiry_block.unwrap_or(self.expiry_block),
169            leverage: self.leverage,
170            post_only: self.post_only,
171            fill_or_kill: self.fill_or_kill,
172            immediate_or_cancel: self.immediate_or_cancel,
173            builder: self.builder,
174            // Preserve linked list info (may be stale after update, but we maintain
175            // ordering separately in BookLevel via sequence numbers)
176            prev_order_id: self.prev_order_id,
177            next_order_id: self.next_order_id,
178        }
179    }
180
181    pub(crate) fn update_if_expired(&mut self, instant: types::StateInstant) -> bool {
182        if self.expiry_block != 0
183            && self.expiry_block <= instant.block_number()
184            && !self.is_expired()
185        {
186            // Just updating instant so `is_expired` returns true
187            self.instant = instant;
188            true
189        } else {
190            false
191        }
192    }
193
194    #[allow(unused)]
195    pub(crate) fn for_testing(r#type: types::OrderType, price: UD64, size: UD64) -> Self {
196        Self {
197            instant: types::StateInstant::new(0, 0),
198            request_id: None,
199            client_order_id: None,
200            order_id: NonZeroU16::MIN,
201            r#type,
202            account_id: 0,
203            price,
204            size,
205            placed_size: Some(size),
206            expiry_block: 0,
207            leverage: UD64::ZERO,
208            post_only: None,
209            fill_or_kill: None,
210            immediate_or_cancel: None,
211            builder: None,
212            prev_order_id: None,
213            next_order_id: None,
214        }
215    }
216
217    /// Create an order for L3 testing with full control over block_number,
218    /// order_id, account_id.
219    #[allow(unused)]
220    pub(crate) fn for_l3_testing(
221        r#type: types::OrderType,
222        price: UD64,
223        size: UD64,
224        block_number: u64,
225        order_id: types::OrderId,
226        account_id: types::AccountId,
227    ) -> Self {
228        Self {
229            instant: types::StateInstant::new(block_number, 0),
230            request_id: None,
231            client_order_id: None,
232            order_id,
233            r#type,
234            account_id,
235            price,
236            size,
237            placed_size: Some(size),
238            expiry_block: 0,
239            leverage: UD64::ZERO,
240            post_only: None,
241            fill_or_kill: None,
242            immediate_or_cancel: None,
243            builder: None,
244            prev_order_id: None,
245            next_order_id: None,
246        }
247    }
248
249    /// Create an order for L3 testing with linked list pointers (for snapshot
250    /// reconstruction tests).
251    #[allow(unused, clippy::too_many_arguments)]
252    pub(crate) fn for_l3_testing_with_links(
253        r#type: types::OrderType,
254        price: UD64,
255        size: UD64,
256        block_number: u64,
257        order_id: types::OrderId,
258        account_id: types::AccountId,
259        prev_order_id: Option<types::OrderId>,
260        next_order_id: Option<types::OrderId>,
261    ) -> Self {
262        Self {
263            instant: types::StateInstant::new(block_number, 0),
264            request_id: None,
265            client_order_id: None,
266            order_id,
267            r#type,
268            account_id,
269            price,
270            size,
271            placed_size: Some(size),
272            expiry_block: 0,
273            leverage: UD64::ZERO,
274            post_only: None,
275            fill_or_kill: None,
276            immediate_or_cancel: None,
277            builder: None,
278            prev_order_id,
279            next_order_id,
280        }
281    }
282
283    /// Create a copy with updated size (for testing partial fills).
284    #[allow(unused)]
285    pub(crate) fn with_size(&self, size: UD64) -> Self {
286        Self {
287            instant: self.instant,
288            request_id: self.request_id,
289            client_order_id: self.client_order_id,
290            order_id: self.order_id,
291            r#type: self.r#type,
292            account_id: self.account_id,
293            price: self.price,
294            size,
295            placed_size: self.placed_size,
296            expiry_block: self.expiry_block,
297            leverage: self.leverage,
298            post_only: self.post_only,
299            fill_or_kill: self.fill_or_kill,
300            immediate_or_cancel: self.immediate_or_cancel,
301            builder: self.builder,
302            prev_order_id: self.prev_order_id,
303            next_order_id: self.next_order_id,
304        }
305    }
306
307    /// Create a copy with updated price (for testing price changes).
308    #[allow(unused)]
309    pub(crate) fn with_price(&self, price: UD64) -> Self {
310        Self {
311            instant: self.instant,
312            request_id: self.request_id,
313            client_order_id: self.client_order_id,
314            order_id: self.order_id,
315            r#type: self.r#type,
316            account_id: self.account_id,
317            price,
318            size: self.size,
319            placed_size: self.placed_size,
320            expiry_block: self.expiry_block,
321            leverage: self.leverage,
322            post_only: self.post_only,
323            fill_or_kill: self.fill_or_kill,
324            immediate_or_cancel: self.immediate_or_cancel,
325            builder: self.builder,
326            prev_order_id: self.prev_order_id,
327            next_order_id: self.next_order_id,
328        }
329    }
330
331    /// Create a copy with updated expiry block (for testing expiry changes).
332    #[allow(unused)]
333    pub(crate) fn with_expiry_block(&self, expiry_block: u64) -> Self {
334        Self {
335            instant: self.instant,
336            request_id: self.request_id,
337            client_order_id: self.client_order_id,
338            order_id: self.order_id,
339            r#type: self.r#type,
340            account_id: self.account_id,
341            price: self.price,
342            size: self.size,
343            placed_size: self.placed_size,
344            expiry_block,
345            leverage: self.leverage,
346            post_only: self.post_only,
347            fill_or_kill: self.fill_or_kill,
348            immediate_or_cancel: self.immediate_or_cancel,
349            builder: self.builder,
350            prev_order_id: self.prev_order_id,
351            next_order_id: self.next_order_id,
352        }
353    }
354
355    /// Create a copy with linked list pointers (for testing snapshot
356    /// reconstruction).
357    #[allow(unused)]
358    pub(crate) fn with_links(
359        &self,
360        prev_order_id: Option<types::OrderId>,
361        next_order_id: Option<types::OrderId>,
362    ) -> Self {
363        Self {
364            instant: self.instant,
365            request_id: self.request_id,
366            client_order_id: self.client_order_id,
367            order_id: self.order_id,
368            r#type: self.r#type,
369            account_id: self.account_id,
370            price: self.price,
371            size: self.size,
372            placed_size: self.placed_size,
373            expiry_block: self.expiry_block,
374            leverage: self.leverage,
375            post_only: self.post_only,
376            fill_or_kill: self.fill_or_kill,
377            immediate_or_cancel: self.immediate_or_cancel,
378            builder: self.builder,
379            prev_order_id,
380            next_order_id,
381        }
382    }
383
384    /// Create a copy with updated client order id.
385    #[allow(unused)]
386    pub(crate) fn with_client_order_id(&self, client_order_id: types::RequestId) -> Self {
387        Self {
388            instant: self.instant,
389            request_id: self.request_id,
390            client_order_id: Some(client_order_id),
391            order_id: self.order_id,
392            r#type: self.r#type,
393            account_id: self.account_id,
394            price: self.price,
395            size: self.size,
396            placed_size: self.placed_size,
397            expiry_block: self.expiry_block,
398            leverage: self.leverage,
399            post_only: self.post_only,
400            fill_or_kill: self.fill_or_kill,
401            immediate_or_cancel: self.immediate_or_cancel,
402            builder: self.builder,
403            prev_order_id: self.prev_order_id,
404            next_order_id: self.next_order_id,
405        }
406    }
407
408    /// Instant the order state is consistent with or was last updated at.
409    pub fn instant(&self) -> types::StateInstant { self.instant }
410
411    /// ID of the request this order was posted or updated by.
412    /// Available only from real-time events, not from the initial snapshot.
413    pub fn request_id(&self) -> Option<types::RequestId> { self.request_id }
414
415    /// Client order ID = ID of the request this order was placed by.
416    /// Available only from real-time events, not from the initial snapshot.
417    pub fn client_order_id(&self) -> Option<types::RequestId> { self.client_order_id }
418
419    /// ID of the order in the book.
420    pub fn order_id(&self) -> types::OrderId { self.order_id }
421
422    /// Type of the order.
423    pub fn r#type(&self) -> types::OrderType { self.r#type }
424
425    /// ID of the account issued the order.
426    pub fn account_id(&self) -> types::AccountId { self.account_id }
427
428    /// Limit price of the order.
429    pub fn price(&self) -> UD64 { self.price }
430
431    /// Current size of the order.
432    pub fn size(&self) -> UD64 { self.size }
433
434    /// Size of the order that was placed.
435    /// Available only from real-time events, not from the initial snapshot.
436    pub fn placed_size(&self) -> Option<UD64> { self.placed_size }
437
438    /// Filled size of the order.
439    /// Available only from real-time events, not from the initial snapshot.
440    pub fn filled_size(&self) -> Option<UD64> {
441        self.placed_size.map(|placed_size| placed_size - self.size)
442    }
443
444    /// Expiry block of the order, zero if was not specified.
445    pub fn expiry_block(&self) -> u64 { self.expiry_block }
446
447    /// Check if the order is expired.
448    /// NOTE: Valid only after the end of expiry block processing.
449    pub fn is_expired(&self) -> bool {
450        self.expiry_block != 0 && self.expiry_block <= self.instant.block_number()
451    }
452
453    /// Leverage of the order.
454    pub fn leverage(&self) -> UD64 { self.leverage }
455
456    /// Post-only flag.
457    /// Available only from real-time events, not from the initial snapshot.
458    pub fn post_only(&self) -> Option<bool> { self.post_only }
459
460    /// Fill-or-fill flag.
461    /// Available only from real-time events, not from the initial snapshot.
462    pub fn fill_or_kill(&self) -> Option<bool> { self.fill_or_kill }
463
464    /// Immediate-or-cancel flag.
465    /// Available only from real-time events, not from the initial snapshot.
466    pub fn immediate_or_cancel(&self) -> Option<bool> { self.immediate_or_cancel }
467
468    /// Builder the order is attributed to, along with the additive fee rate
469    /// that builder charges on the size the order adds. `None` means no
470    /// builder.
471    ///
472    /// Unlike the flags above, attribution is persisted with the resting order
473    /// on-chain, so it is available both from the initial snapshot (contract
474    /// v1.1.7.4+, via `getOrderV2`) and from the `OrderRequestV2` event stream.
475    /// What a builder actually earned per fill is reported by the `builder_fee`
476    /// of [`super::OrderEventType::Filled`].
477    pub fn builder(&self) -> Option<types::BuilderAttribution> { self.builder }
478
479    /// Previous order ID in the FIFO queue at this price level.
480    /// Available from snapshot, None for newly placed orders or if this is the
481    /// first order.
482    pub fn prev_order_id(&self) -> Option<types::OrderId> { self.prev_order_id }
483
484    /// Next order ID in the FIFO queue at this price level.
485    /// Available from snapshot, None for newly placed orders or if this is the
486    /// last order.
487    pub fn next_order_id(&self) -> Option<types::OrderId> { self.next_order_id }
488}
489
490impl std::fmt::Display for Order {
491    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492        if f.alternate() {
493            // Short order representation
494            let mut short = format!(
495                "{} {:#} #{} 👤{}",
496                self.size(),
497                self.r#type(),
498                self.order_id(),
499                self.account_id(),
500            );
501            if self.expiry_block > 0 {
502                short.push_str(format!(" ⏳{}", self.expiry_block).as_str());
503            }
504            write!(f, "[{}]", short)
505        } else {
506            write!(
507                f,
508                "[{}@{} {:#} #{} acc:{} rq:{} exp:{}{} lev:{}]",
509                self.size(),
510                self.price(),
511                self.r#type(),
512                self.order_id(),
513                self.account_id(),
514                self.request_id().unwrap_or_default(),
515                self.expiry_block(),
516                if self.is_expired() { " (expired)" } else { "" },
517                self.leverage(),
518            )
519        }
520    }
521}
522
523#[cfg(feature = "display")]
524impl tabled::Tabled for Order {
525    const LENGTH: usize = 13;
526
527    fn fields(&self) -> Vec<std::borrow::Cow<'_, str>> {
528        use colored::Colorize;
529
530        use crate::types::OrderSide;
531
532        vec![
533            match self.r#type.side() {
534                OrderSide::Ask => self.price().to_string().red().to_string().into(),
535                OrderSide::Bid => self.price().to_string().green().to_string().into(),
536            },
537            match self.r#type.side() {
538                OrderSide::Ask => self.size().to_string().red().to_string().into(),
539                OrderSide::Bid => self.size().to_string().green().to_string().into(),
540            },
541            match self.r#type.side() {
542                OrderSide::Ask => self.r#type().to_string().red().to_string().into(),
543                OrderSide::Bid => self.r#type().to_string().green().to_string().into(),
544            },
545            self.order_id().to_string().into(),
546            self.account_id().to_string().into(),
547            if let Some(request_id) = self.request_id() {
548                request_id.to_string().into()
549            } else {
550                "-".to_string().into()
551            },
552            if let Some(client_order_id) = self.client_order_id() {
553                client_order_id.to_string().into()
554            } else {
555                "-".to_string().into()
556            },
557            if self.expiry_block() > 0 {
558                if self.is_expired() {
559                    (self.expiry_block().to_string() + " (expired)")
560                        .bright_red()
561                        .to_string()
562                        .into()
563                } else {
564                    self.expiry_block().to_string().into()
565                }
566            } else {
567                "-".to_string().into()
568            },
569            self.leverage().to_string().into(),
570            if self.post_only.unwrap_or_default() { "+" } else { "" }
571                .to_string()
572                .into(),
573            if self.fill_or_kill.unwrap_or_default() { "+" } else { "" }
574                .to_string()
575                .into(),
576            if self.immediate_or_cancel.unwrap_or_default() { "+" } else { "" }
577                .to_string()
578                .into(),
579            match self.builder {
580                Some(builder) => format!("{}@{}", builder.builder_id(), builder.fee()).into(),
581                None => "-".to_string().into(),
582            },
583        ]
584    }
585
586    fn headers() -> Vec<std::borrow::Cow<'static, str>> {
587        vec![
588            "Price".into(),
589            "Size".into(),
590            "Type".into(),
591            "Order ID".into(),
592            "Account ID".into(),
593            "Request ID".into(),
594            "Client Order ID".into(),
595            "Expiry Block".into(),
596            "Leverage".into(),
597            "PO".into(),
598            "FoK".into(),
599            "IoC".into(),
600            "Builder".into(),
601        ]
602    }
603}