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