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}