Skip to main content

rustrade_execution/order/
state.rs

1use crate::{
2    error::OrderError,
3    order::id::{OrderId, VenueOrderId},
4};
5use chrono::{DateTime, Utc};
6use derive_more::{Constructor, From};
7use rust_decimal::Decimal;
8use rustrade_instrument::{
9    asset::{AssetIndex, name::AssetNameExchange},
10    instrument::{InstrumentIndex, name::InstrumentNameExchange},
11};
12use serde::{Deserialize, Serialize};
13
14/// Convenient type alias for an [`OrderState`] keyed with [`AssetNameExchange`]
15/// and [`InstrumentNameExchange`].
16pub type UnindexedOrderState = OrderState<AssetNameExchange, InstrumentNameExchange>;
17
18/// Convenient type alias for an [`InactiveOrderState`] keyed with [`AssetNameExchange`] and
19/// [`InstrumentNameExchange`].
20pub type UnindexedInactiveOrderState =
21    InactiveOrderState<AssetNameExchange, InstrumentNameExchange>;
22
23#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, From)]
24pub enum OrderState<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
25    Active(ActiveOrderState),
26    Inactive(InactiveOrderState<AssetKey, InstrumentKey>),
27}
28
29impl<AssetKey, InstrumentKey> OrderState<AssetKey, InstrumentKey> {
30    pub fn active<S>(state: S) -> Self
31    where
32        S: Into<ActiveOrderState>,
33    {
34        OrderState::Active(state.into())
35    }
36
37    pub fn inactive<S>(state: S) -> Self
38    where
39        S: Into<InactiveOrderState<AssetKey, InstrumentKey>>,
40    {
41        OrderState::Inactive(state.into())
42    }
43
44    pub fn fully_filled(filled: Filled) -> Self {
45        Self::Inactive(InactiveOrderState::FullyFilled(filled))
46    }
47
48    pub fn expired(expired: Expired) -> Self {
49        Self::Inactive(InactiveOrderState::Expired(expired))
50    }
51
52    pub fn time_exchange(&self) -> Option<DateTime<Utc>> {
53        match self {
54            Self::Active(active) => match active {
55                ActiveOrderState::OpenInFlight(_) => None,
56                ActiveOrderState::Open(state) => Some(state.time_exchange),
57                ActiveOrderState::CancelInFlight(state) => {
58                    state.order.as_ref().map(|order| order.time_exchange)
59                }
60            },
61            Self::Inactive(inactive) => match inactive {
62                InactiveOrderState::Cancelled(state) => Some(state.time_exchange),
63                InactiveOrderState::FullyFilled(state) => Some(state.time_exchange),
64                InactiveOrderState::Expired(state) => Some(state.time_exchange),
65                InactiveOrderState::OpenFailed(_) => None,
66            },
67        }
68    }
69
70    /// Returns `true` if the order was not rejected at placement.
71    ///
72    /// Returns `true` for all states except `Inactive(OpenFailed(_))`:
73    /// - `Active(_)` — order is working on the exchange
74    /// - `Inactive(FullyFilled(_))` — order completed successfully
75    /// - `Inactive(Cancelled(_))` — order was accepted then cancelled
76    /// - `Inactive(Expired(_))` — order was accepted then expired
77    ///
78    /// This is the opposite of [`is_failed()`](Self::is_failed).
79    pub fn is_accepted(&self) -> bool {
80        !self.is_failed()
81    }
82
83    /// Returns `true` if the order failed to open.
84    pub fn is_failed(&self) -> bool {
85        matches!(self, Self::Inactive(InactiveOrderState::OpenFailed(_)))
86    }
87}
88
89#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, From)]
90pub enum ActiveOrderState {
91    OpenInFlight(OpenInFlight),
92    Open(Open),
93    CancelInFlight(CancelInFlight),
94}
95
96impl ActiveOrderState {
97    /// When the request this order awaits an answer to was sent: `Some` for
98    /// [`OpenInFlight`](Self::OpenInFlight) and [`CancelInFlight`](Self::CancelInFlight), `None`
99    /// for [`Open`](Self::Open).
100    pub fn time_sent(&self) -> Option<DateTime<Utc>> {
101        match self {
102            Self::OpenInFlight(open) => Some(open.time_sent),
103            Self::Open(_) => None,
104            Self::CancelInFlight(cancel) => Some(cancel.time_sent),
105        }
106    }
107
108    pub fn open_meta(&self) -> Option<&Open> {
109        match self {
110            Self::OpenInFlight(_) => None,
111            Self::Open(open) => Some(open),
112            Self::CancelInFlight(cancel) => cancel.order.as_ref(),
113        }
114    }
115}
116
117/// An open request sent to the exchange that it has not yet answered.
118#[derive(
119    Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
120)]
121pub struct OpenInFlight {
122    /// When the request was sent, by the sender's clock: the engine's `EngineClock`, so a
123    /// backtest stamps the simulated time.
124    pub time_sent: DateTime<Utc>,
125}
126
127/// An order the exchange reports as working, in the state the exchange last reported it.
128///
129/// Successive `Open` values for one order describe that order over time -- an acknowledgement,
130/// then each partial fill -- so consumers keep the later of two and discard the other. Use
131/// [`Open::is_superseded_by`] to decide which is which; it does not rest on
132/// [`Open::time_exchange`] alone, because not every venue supplies a usable one. Producers must
133/// still stamp that field correctly where they can; see it for the obligation.
134#[derive(
135    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
136)]
137pub struct Open {
138    /// How the venue addresses this order.
139    ///
140    /// [`VenueOrderId::ClientAssigned`] when the venue acknowledged the order without assigning an
141    /// identifier of its own. A consumer deciding whether two `Open` values describe the same
142    /// venue order must go through [`VenueOrderId::assigned`] rather than comparing this field
143    /// directly, because two `ClientAssigned` values are equal without being evidence of anything.
144    pub id: VenueOrderId,
145    /// When the exchange last reported this state -- **not** when the order was created.
146    ///
147    /// ## Producer obligation
148    ///
149    /// A client building this from a venue response must use the venue's last-update field
150    /// (Binance `updateTime`, Alpaca `updated_at`) rather than its creation field. Creation time
151    /// is identical across every snapshot of one order, so a snapshot stamped with it cannot be
152    /// ordered against the states that followed it and is discarded as stale by any consumer
153    /// applying the rule above -- silently, and precisely for the partially-filled orders a
154    /// reconciliation fetch exists to repair.
155    pub time_exchange: DateTime<Utc>,
156    /// Cumulative quantity filled across every execution against this order, as the exchange
157    /// reports it -- not the size of the most recent execution.
158    pub filled_quantity: Decimal,
159}
160
161impl Open {
162    pub fn quantity_remaining(&self, initial_quantity: Decimal) -> Decimal {
163        initial_quantity - self.filled_quantity
164    }
165
166    /// Whether `update` describes a later state of this order than `self` does, and so should
167    /// replace it.
168    ///
169    /// Ordering rests on two properties of a single venue order:
170    ///
171    /// 1. **[`Open::time_exchange`]** is non-decreasing, where the venue supplies a usable one.
172    /// 2. **[`Open::filled_quantity`]** is non-decreasing *always* -- cumulative fill is
173    ///    append-only, because an execution against an order cannot be un-executed.
174    ///
175    /// The second is the stronger property, and it is used in both directions. An update
176    /// reporting strictly **less** filled is out of sequence whatever its stamp says, so it is
177    /// refused outright. Otherwise an update is taken as later when it is stamped no earlier
178    /// **or** when it reports strictly more filled.
179    ///
180    /// Each direction carries a different class of venue:
181    ///
182    /// - Refusing a lower cumulative is what orders a venue whose stamps are *locally* applied.
183    ///   IBKR's `orderStatus` callback carries no timestamp field at all, so its client stamps
184    ///   `Utc::now()` on receipt; those rise in arrival order, which makes the stamp admit every
185    ///   snapshot and degrades ordering to last-writer-wins. The fill invariant still rejects a
186    ///   snapshot that overtook a newer one in flight.
187    /// - Admitting a higher cumulative is what rescues a venue whose stamps are *too old* -- a
188    ///   reconciliation fetch pinned to the order's creation time, whose snapshot would otherwise
189    ///   be discarded along with the only accurate cumulative it carried.
190    ///
191    /// # Caller obligation
192    ///
193    /// Both values must already be known to describe the *same* venue order. This orders states,
194    /// it does not establish identity -- check that first with [`VenueOrderId::contradicts`], or
195    /// a snapshot belonging to a different order will be ordered against this one as though it
196    /// were a later state of it.
197    ///
198    /// # Known limitations
199    ///
200    /// Two snapshots reporting the same cumulative fill are ordered on the stamp alone, so a
201    /// venue that supplies no usable one is unordered across them. That covers an order's whole
202    /// life before its first execution, which is why the producer obligation on
203    /// [`Open::time_exchange`] still stands.
204    ///
205    /// A venue that *reduces* a reported cumulative -- busting or correcting an execution -- has
206    /// its correction refused, since a decrease is indistinguishable from out-of-sequence
207    /// delivery. The order is left on the higher cumulative until a later snapshot moves it. A
208    /// venue that reports the bust or correction itself does so as
209    /// [`AccountEventKind::TradeAmended`](crate::AccountEventKind::TradeAmended).
210    ///
211    /// A caller replacing `self` with `update` wholesale adopts `update`'s `time_exchange` too,
212    /// which may be the earlier of the two. That is deliberate: an `Open` is one state the venue
213    /// reported, and carrying the newer stamp onto the other's quantities would synthesise a
214    /// state the venue never reported.
215    pub fn is_superseded_by(&self, update: &Self) -> bool {
216        if update.filled_quantity < self.filled_quantity {
217            return false;
218        }
219
220        self.time_exchange <= update.time_exchange || update.filled_quantity > self.filled_quantity
221    }
222}
223
224/// Metadata for a fully filled order.
225///
226/// Unlike [`Open`], this represents an order that has completed execution
227/// and is no longer active on the exchange.
228#[derive(
229    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
230)]
231pub struct Filled {
232    pub id: OrderId,
233    pub time_exchange: DateTime<Utc>,
234    pub filled_quantity: Decimal,
235    /// Volume-weighted average execution price across all fills.
236    ///
237    /// `Some` when the exchange provides it in the response, `None` otherwise.
238    /// When `None`, downstream consumers should compute from individual fill events.
239    pub avg_price: Option<Decimal>,
240}
241
242/// A cancel request sent to the exchange that it has not yet answered.
243#[derive(
244    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
245)]
246pub struct CancelInFlight {
247    /// The order as last reported working, if it was before the cancel was sent.
248    pub order: Option<Open>,
249    /// When the cancel was sent, by the sender's clock, as [`OpenInFlight::time_sent`].
250    pub time_sent: DateTime<Utc>,
251}
252
253#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, From)]
254pub enum InactiveOrderState<AssetKey, InstrumentKey> {
255    Cancelled(Cancelled),
256    FullyFilled(Filled),
257    OpenFailed(OrderError<AssetKey, InstrumentKey>),
258    Expired(Expired),
259}
260
261/// Metadata for a cancelled order.
262///
263/// Includes `filled_quantity` to handle IOC (Immediate-Or-Cancel) orders
264/// that partially fill before the remainder is cancelled.
265#[derive(
266    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
267)]
268pub struct Cancelled {
269    pub id: OrderId,
270    /// Cancellation timestamp.
271    ///
272    /// Normally the venue-reported cancel time. Some venues omit a timestamp on their cancel
273    /// response (e.g. Binance margin cancels carry no `transactTime`); for those the client falls
274    /// back to the local receive time, which can differ from the true venue cancel time by network
275    /// latency. Consumers building fill ledgers or P&L should not assume sub-second venue accuracy.
276    pub time_exchange: DateTime<Utc>,
277    /// Quantity filled before the order was cancelled, or `None` when the venue did not report
278    /// it.
279    ///
280    /// `Some(0)` for an order cancelled with no fills (e.g., GTC limit order cancelled by user).
281    /// Non-zero for IOC orders that partially filled before cancellation.
282    ///
283    /// `None` is not zero: it means the fill is unknown, so the order may have partly filled.
284    /// Some venues never report it in the response to a cancel request (Alpaca, Hyperliquid,
285    /// IBKR), though their account streams may, and others omit it rarely.
286    /// To learn it, read the order's fills from the account stream, or ask the venue through
287    /// [`OrderStatusClient::fetch_ended_orders`](crate::client::OrderStatusClient::fetch_ended_orders)
288    /// where the client implements it.
289    pub filled_quantity: Option<Decimal>,
290}
291
292/// Metadata for an expired order.
293///
294/// Includes `filled_quantity` to handle GTD (Good-Till-Date) orders
295/// that partially fill before expiration.
296#[derive(
297    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
298)]
299pub struct Expired {
300    pub id: OrderId,
301    pub time_exchange: DateTime<Utc>,
302    /// Quantity filled before the order expired, or `None` when the venue did not report it.
303    ///
304    /// `None` is not zero: see [`Cancelled::filled_quantity`].
305    pub filled_quantity: Option<Decimal>,
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use chrono::TimeDelta;
312    use rust_decimal_macros::dec;
313
314    /// An `Open` for one venue order, `secs` after an arbitrary epoch, reporting `filled`.
315    fn open(secs: i64, filled: Decimal) -> Open {
316        Open {
317            id: VenueOrderId::Assigned(OrderId::new("1")),
318            time_exchange: DateTime::<Utc>::MIN_UTC + TimeDelta::seconds(secs),
319            filled_quantity: filled,
320        }
321    }
322
323    #[test]
324    fn a_later_stamp_supersedes() {
325        assert!(open(0, dec!(0)).is_superseded_by(&open(1, dec!(0))));
326    }
327
328    /// Deliberately `<=`. Venues report at a coarser resolution than they act, so two states of
329    /// one order routinely share a stamp; the later-arriving one is taken as the later state.
330    #[test]
331    fn an_equal_stamp_supersedes() {
332        assert!(open(1, dec!(0)).is_superseded_by(&open(1, dec!(0))));
333    }
334
335    #[test]
336    fn an_earlier_stamp_alone_does_not_supersede() {
337        assert!(!open(1, dec!(0)).is_superseded_by(&open(0, dec!(0))));
338    }
339
340    /// The creation-stamped reconciliation snapshot. Its stamp cannot place it after the states
341    /// that followed the order's acknowledgement, but the cumulative it carries is evidence
342    /// enough on its own.
343    #[test]
344    fn more_filled_supersedes_an_earlier_stamp() {
345        assert!(open(1, dec!(0.3)).is_superseded_by(&open(0, dec!(0.6))));
346    }
347
348    /// The case the stamp cannot catch at a venue that stamps locally on receipt: a snapshot that
349    /// overtook a newer one in flight carries the later stamp and the earlier state. Cumulative
350    /// fill is append-only, so reporting less of it is proof the snapshot is out of sequence.
351    #[test]
352    fn less_filled_is_refused_despite_a_later_stamp() {
353        assert!(!open(0, dec!(0.6)).is_superseded_by(&open(1, dec!(0.3))));
354    }
355
356    /// Both keys together are still weaker than a correct stamp: where the cumulative does not
357    /// move, ordering falls back to the stamp alone. This is the whole of an order's life before
358    /// its first execution.
359    #[test]
360    fn an_unmoved_cumulative_leaves_ordering_to_the_stamp() {
361        assert!(!open(1, dec!(0.3)).is_superseded_by(&open(0, dec!(0.3))));
362        assert!(open(0, dec!(0.3)).is_superseded_by(&open(1, dec!(0.3))));
363    }
364}