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}