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