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}