Skip to main content

brokapi_sansio/
order_fsm.rs

1//! In-memory order lifecycle FSM, level-1 tests.
2
3use std::collections::HashMap;
4use std::time::Instant;
5
6use brokapi_core::{BrokerOrderId, ClientOrderId, OrderState, Qty};
7
8/// Something that happened to an order, fed to [`OrderFsm::apply`].
9///
10/// See [`OrderFsm`] for which signal is valid in which state.
11#[derive(Debug, Clone, PartialEq)]
12pub enum OrderSignal {
13    /// The place command went out on the wire. Changes nothing, the order is already in flight.
14    PlaceSent,
15    /// The broker accepted the order and gave it an id.
16    Ack {
17        /// The broker's id for the order.
18        broker_id: BrokerOrderId,
19    },
20    /// Part of the order was filled.
21    PartialFill {
22        /// Quantity filled so far, in total.
23        filled: Qty,
24        /// Quantity still open.
25        left: Qty,
26    },
27    /// The order is completely filled.
28    Filled,
29    /// The order was cancelled.
30    Cancelled,
31    /// The broker refused the order.
32    Rejected {
33        /// The broker's reason, for logs.
34        reason: String,
35    },
36    /// No answer to the place command came in time. The order may or may not exist at the broker.
37    Timeout,
38}
39
40/// Why a signal could not be applied.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum FsmError {
43    /// [`OrderRegistry::apply`] was given a client order id it does not track.
44    UnknownOrder(ClientOrderId),
45    /// The signal is not valid in the order's current state. The state is left as it was.
46    InvalidTransition {
47        /// Name of the current state, for example `"Filled"`.
48        from: &'static str,
49        /// Name of the signal, for example `"Cancelled"`.
50        signal: &'static str,
51    },
52}
53
54/// The life of one order as a finite state machine over [`OrderState`].
55///
56/// The order starts `InFlight` (the place command is on the wire). A broker answer moves it on. If
57/// no answer comes, [`OrderSignal::Timeout`] moves it to `Unknown`. `Unknown` is not a failure: the
58/// order may exist at the broker, and a later ack, fill or cancel (from the stream or from a
59/// reconcile) settles it. The command is never repeated, because a retry could create a second
60/// order.
61///
62/// The machine takes the order in which news really arrives. The stream of fills can be faster than the
63/// answer to the place command, so a fill or a cancel is valid in `InFlight` too.
64///
65/// | State | Signal -> new state |
66/// |---|---|
67/// | `InFlight` | `PlaceSent` -> no change, `Ack` -> `Acked`, `PartialFill` -> `PartiallyFilled`, `Filled`, `Cancelled`, `Rejected`, `Timeout` -> `Unknown` |
68/// | `Unknown` | `Ack` -> `Acked`, `PartialFill` -> `PartiallyFilled`, `Filled`, `Cancelled`, `Rejected`, `Timeout` -> no change |
69/// | `Acked` | `PartialFill` -> `PartiallyFilled`, `Filled`, `Cancelled`, `Rejected`, `Ack` -> no change (late repeat) |
70/// | `PartiallyFilled` | `PartialFill` -> `PartiallyFilled` (new quantities, but a smaller `filled` than known is a stale snapshot and changes nothing), `Filled`, `Cancelled`, `Rejected`, `Ack` -> no change |
71/// | `Filled`, `Cancelled`, `Rejected` | the same final state again, and a late `Ack` or `PartialFill` -> no change. A different final state (a cancel of a filled order) -> [`FsmError::InvalidTransition`] |
72///
73/// News that is late or repeated but agrees with what is known changes nothing (`Ok(None)`). News that
74/// contradicts it is an error and the state is left as it was (the order book of `brokapi-runtime` logs
75/// it at warn level).
76///
77/// # Examples
78///
79/// ```
80/// use std::time::Instant;
81/// use brokapi_core::{BrokerOrderId, ClientOrderId, OrderState};
82/// use brokapi_sansio::{FsmError, OrderFsm, OrderSignal};
83///
84/// let cid = ClientOrderId::new_v4();
85/// let now = Instant::now();
86/// let mut fsm = OrderFsm::new(cid, now);
87/// assert!(matches!(fsm.state(), OrderState::InFlight { .. }));
88///
89/// // No answer in time: the outcome is unknown.
90/// let next = fsm.apply(OrderSignal::Timeout, now)?;
91/// assert!(matches!(next, Some(OrderState::Unknown { .. })));
92///
93/// // A second timeout changes nothing: Ok(None).
94/// assert_eq!(fsm.apply(OrderSignal::Timeout, now), Ok(None));
95///
96/// // The stream later shows the order. It was alive all along.
97/// let next = fsm.apply(OrderSignal::Ack { broker_id: BrokerOrderId::new("42") }, now)?;
98/// assert!(matches!(next, Some(OrderState::Acked { .. })));
99///
100/// fsm.apply(OrderSignal::Filled, now)?;
101/// // A finished order accepts nothing that contradicts it.
102/// assert!(matches!(
103///     fsm.apply(OrderSignal::Cancelled, now),
104///     Err(FsmError::InvalidTransition { from: "Filled", signal: "Cancelled" })
105/// ));
106/// # Ok::<(), FsmError>(())
107/// ```
108#[derive(Debug, Clone)]
109pub struct OrderFsm {
110    state: OrderState,
111}
112
113impl OrderFsm {
114    /// Starts tracking an order whose place command is sent at `now`, in state `InFlight`.
115    pub fn new(cid: ClientOrderId, now: Instant) -> Self {
116        Self {
117            state: OrderState::InFlight { cid, sent_at: now },
118        }
119    }
120
121    /// Starts from a known state, for example an order read back from the broker.
122    pub fn from_state(state: OrderState) -> Self {
123        Self { state }
124    }
125
126    /// The current state.
127    pub fn state(&self) -> &OrderState {
128        &self.state
129    }
130
131    /// The client order id, the same in every state.
132    pub fn cid(&self) -> ClientOrderId {
133        self.state.cid()
134    }
135
136    /// Applies a signal at `now`.
137    ///
138    /// Returns `Ok(Some(new_state))` when the state changed, and `Ok(None)` when the signal is
139    /// valid but changes nothing (a repeated `Timeout` in `Unknown`, `PlaceSent` in `InFlight`, a
140    /// `PartialFill` with the same quantities). `now` is stored in the `Unknown` state as the time
141    /// the doubt began.
142    ///
143    /// # Errors
144    ///
145    /// [`FsmError::InvalidTransition`] when the signal is not valid in the current state, see the
146    /// table in the type docs. The state is left unchanged.
147    pub fn apply(
148        &mut self,
149        signal: OrderSignal,
150        now: Instant,
151    ) -> Result<Option<OrderState>, FsmError> {
152        let prev = self.state.clone();
153        let next = transition(&self.state, signal, now)?;
154        if next != prev {
155            self.state = next.clone();
156            Ok(Some(next))
157        } else {
158            Ok(None)
159        }
160    }
161}
162
163fn transition(
164    state: &OrderState,
165    signal: OrderSignal,
166    now: Instant,
167) -> Result<OrderState, FsmError> {
168    let cid = state.cid();
169
170    let signal_label = signal_name(&signal);
171
172    match (state, signal) {
173        (OrderState::InFlight { .. }, OrderSignal::PlaceSent) => Ok(state.clone()),
174
175        // The stream can be faster than the answer to the place command, and an order that is only
176        // `Unknown` may have moved on without us: a fill, a partial fill or a cancel is a fact, so it is
177        // taken from every state that has not seen it yet.
178        (
179            OrderState::InFlight { .. } | OrderState::Unknown { .. },
180            OrderSignal::Ack { broker_id },
181        ) => Ok(OrderState::Acked { cid, broker_id }),
182        (
183            OrderState::InFlight { .. } | OrderState::Unknown { .. } | OrderState::Acked { .. },
184            OrderSignal::PartialFill { filled, left },
185        ) => Ok(OrderState::PartiallyFilled { cid, filled, left }),
186        (
187            OrderState::InFlight { .. }
188            | OrderState::Unknown { .. }
189            | OrderState::Acked { .. }
190            | OrderState::PartiallyFilled { .. },
191            OrderSignal::Filled,
192        ) => Ok(OrderState::Filled { cid }),
193        (
194            OrderState::InFlight { .. }
195            | OrderState::Unknown { .. }
196            | OrderState::Acked { .. }
197            | OrderState::PartiallyFilled { .. },
198            OrderSignal::Cancelled,
199        ) => Ok(OrderState::Cancelled { cid }),
200        (
201            OrderState::InFlight { .. }
202            | OrderState::Unknown { .. }
203            | OrderState::Acked { .. }
204            | OrderState::PartiallyFilled { .. },
205            OrderSignal::Rejected { reason },
206        ) => Ok(OrderState::Rejected { cid, reason }),
207
208        (OrderState::InFlight { .. }, OrderSignal::Timeout) => {
209            Ok(OrderState::Unknown { cid, since: now })
210        }
211
212        (
213            OrderState::PartiallyFilled { filled: have, .. },
214            OrderSignal::PartialFill { filled, left },
215        ) => {
216            // An older snapshot that arrives late must not take the fill back.
217            if filled < *have {
218                Ok(state.clone())
219            } else {
220                Ok(OrderState::PartiallyFilled { cid, filled, left })
221            }
222        }
223
224        // Late, repeated or out-of-order news that agrees with what is known: nothing to change. An
225        // answer to the place command that comes after the fills, a second `Timeout`, the same final
226        // state told twice.
227        (
228            OrderState::Unknown { .. }
229            | OrderState::Acked { .. }
230            | OrderState::PartiallyFilled { .. }
231            | OrderState::Filled { .. }
232            | OrderState::Cancelled { .. }
233            | OrderState::Rejected { .. },
234            OrderSignal::Timeout | OrderSignal::PlaceSent | OrderSignal::Ack { .. },
235        )
236        | (
237            OrderState::Filled { .. } | OrderState::Cancelled { .. } | OrderState::Rejected { .. },
238            OrderSignal::PartialFill { .. },
239        )
240        | (OrderState::Filled { .. }, OrderSignal::Filled)
241        | (OrderState::Cancelled { .. }, OrderSignal::Cancelled)
242        | (OrderState::Rejected { .. }, OrderSignal::Rejected { .. }) => Ok(state.clone()),
243
244        // The rest contradicts what is known (a cancel of a filled order, a fill of a cancelled one).
245        (from, _) => Err(FsmError::InvalidTransition {
246            from: state_name(from),
247            signal: signal_label,
248        }),
249    }
250}
251
252fn state_name(state: &OrderState) -> &'static str {
253    state.name()
254}
255
256fn signal_name(signal: &OrderSignal) -> &'static str {
257    match signal {
258        OrderSignal::PlaceSent => "PlaceSent",
259        OrderSignal::Ack { .. } => "Ack",
260        OrderSignal::PartialFill { .. } => "PartialFill",
261        OrderSignal::Filled => "Filled",
262        OrderSignal::Cancelled => "Cancelled",
263        OrderSignal::Rejected { .. } => "Rejected",
264        OrderSignal::Timeout => "Timeout",
265    }
266}
267
268/// Many [`OrderFsm`]s keyed by client order id.
269///
270/// # Examples
271///
272/// ```
273/// use std::time::{Duration, Instant};
274/// use brokapi_core::{ClientOrderId, OrderState};
275/// use brokapi_sansio::OrderRegistry;
276///
277/// let mut registry = OrderRegistry::new();
278/// let t0 = Instant::now();
279/// let (a, b) = (ClientOrderId::new_v4(), ClientOrderId::new_v4());
280/// registry.register_place(a, t0);
281/// registry.register_place(b, t0 + Duration::from_secs(20));
282///
283/// // 31 s after `a` was sent, with a 30 s limit: only `a` has gone silent.
284/// let changed = registry.on_global_timeout(t0 + Duration::from_secs(31), Duration::from_secs(30));
285/// assert_eq!(changed.len(), 1);
286/// assert_eq!(changed[0].0, a);
287/// assert!(matches!(changed[0].1, OrderState::Unknown { .. }));
288/// assert_eq!(registry.in_flight_cids(), vec![b]);
289/// ```
290#[derive(Debug, Default)]
291pub struct OrderRegistry {
292    orders: HashMap<ClientOrderId, OrderFsm>,
293}
294
295impl OrderRegistry {
296    /// Creates an empty registry.
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    /// Starts tracking `cid` as `InFlight`, sent at `now`. An order already tracked under this cid
302    /// is replaced.
303    pub fn register_place(&mut self, cid: ClientOrderId, now: Instant) {
304        self.orders.insert(cid, OrderFsm::new(cid, now));
305    }
306
307    /// Applies a signal to the order `cid`, see [`OrderFsm::apply`] for the result.
308    ///
309    /// # Errors
310    ///
311    /// [`FsmError::UnknownOrder`] when `cid` is not tracked, and every error of [`OrderFsm::apply`].
312    pub fn apply(
313        &mut self,
314        cid: ClientOrderId,
315        signal: OrderSignal,
316        now: Instant,
317    ) -> Result<Option<OrderState>, FsmError> {
318        let fsm = self
319            .orders
320            .get_mut(&cid)
321            .ok_or(FsmError::UnknownOrder(cid))?;
322        fsm.apply(signal, now)
323    }
324
325    /// The state machine of `cid`, if tracked.
326    pub fn get(&self, cid: ClientOrderId) -> Option<&OrderFsm> {
327        self.orders.get(&cid)
328    }
329
330    /// Stops tracking `cid`, but only if its order is finished (`Filled`, `Cancelled` or `Rejected`).
331    /// Returns `true` when it was removed. A live order, or an unknown cid, is left alone.
332    pub fn forget_finished(&mut self, cid: ClientOrderId) -> bool {
333        let finished = self.orders.get(&cid).is_some_and(|f| f.state().is_final());
334        if finished {
335            self.orders.remove(&cid);
336        }
337        finished
338    }
339
340    /// How many orders are tracked, in any state.
341    pub fn len(&self) -> usize {
342        self.orders.len()
343    }
344
345    /// `true` when no order is tracked.
346    pub fn is_empty(&self) -> bool {
347        self.orders.is_empty()
348    }
349
350    /// The cids of all orders now in state `InFlight`, in no particular order.
351    pub fn in_flight_cids(&self) -> Vec<ClientOrderId> {
352        self.orders
353            .iter()
354            .filter_map(|(cid, fsm)| {
355                if matches!(fsm.state(), OrderState::InFlight { .. }) {
356                    Some(*cid)
357                } else {
358                    None
359                }
360            })
361            .collect()
362    }
363
364    /// Moves every `InFlight` order to `Unknown` now, whatever its age (shutdown).
365    ///
366    /// Returns each changed cid with its new state. Nothing is sent to the broker.
367    pub fn abandon_inflight(&mut self, now: Instant) -> Vec<(ClientOrderId, OrderState)> {
368        let inflight: Vec<ClientOrderId> = self.in_flight_cids();
369        let mut abandoned = Vec::new();
370        for cid in inflight {
371            if let Ok(Some(next)) = self.apply(cid, OrderSignal::Timeout, now) {
372                abandoned.push((cid, next));
373            }
374        }
375        abandoned
376    }
377
378    /// Moves every `InFlight` order that has waited `timeout` or longer (since it was registered) to
379    /// `Unknown`, and returns the changed ones with their new state.
380    ///
381    /// Nothing is sent to the broker. Orders in other states are not touched.
382    pub fn on_global_timeout(
383        &mut self,
384        now: Instant,
385        timeout: std::time::Duration,
386    ) -> Vec<(ClientOrderId, OrderState)> {
387        let mut changed = Vec::new();
388        for (cid, fsm) in &mut self.orders {
389            if let OrderState::InFlight { sent_at, .. } = fsm.state()
390                && now.duration_since(*sent_at) >= timeout
391                && let Ok(Some(next)) = fsm.apply(OrderSignal::Timeout, now)
392            {
393                changed.push((*cid, next));
394            }
395        }
396        changed
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use std::time::Duration;
404
405    fn cid(n: u8) -> ClientOrderId {
406        ClientOrderId::from_bytes([n; 16])
407    }
408
409    fn now() -> Instant {
410        Instant::now()
411    }
412
413    #[test]
414    fn in_flight_ack_to_acked() {
415        let mut fsm = OrderFsm::new(cid(1), now());
416        let next = fsm
417            .apply(
418                OrderSignal::Ack {
419                    broker_id: BrokerOrderId::new("42"),
420                },
421                now(),
422            )
423            .unwrap()
424            .unwrap();
425        assert!(matches!(next, OrderState::Acked { .. }));
426    }
427
428    #[test]
429    fn in_flight_timeout_to_unknown() {
430        let mut fsm = OrderFsm::new(cid(1), now());
431        let next = fsm.apply(OrderSignal::Timeout, now()).unwrap().unwrap();
432        assert!(matches!(next, OrderState::Unknown { .. }));
433    }
434
435    #[test]
436    fn unknown_recovery_via_ack() {
437        let mut fsm = OrderFsm::from_state(OrderState::Unknown {
438            cid: cid(1),
439            since: now(),
440        });
441        let next = fsm
442            .apply(
443                OrderSignal::Ack {
444                    broker_id: BrokerOrderId::new("99"),
445                },
446                now(),
447            )
448            .unwrap()
449            .unwrap();
450        assert!(matches!(next, OrderState::Acked { .. }));
451    }
452
453    #[test]
454    fn terminal_states_reject_signals() {
455        let mut fsm = OrderFsm::from_state(OrderState::Filled { cid: cid(1) });
456        assert!(fsm.apply(OrderSignal::Cancelled, now()).is_err());
457    }
458
459    #[test]
460    fn registry_timeout_batch() {
461        let mut reg = OrderRegistry::new();
462        let t0 = Instant::now();
463        reg.register_place(cid(1), t0);
464        let changed = reg.on_global_timeout(t0 + Duration::from_secs(31), Duration::from_secs(30));
465        assert_eq!(changed.len(), 1);
466        assert!(matches!(changed[0].1, OrderState::Unknown { .. }));
467    }
468
469    fn ack(id: &str) -> OrderSignal {
470        OrderSignal::Ack {
471            broker_id: BrokerOrderId::new(id),
472        }
473    }
474
475    fn partial(filled: i64, left: i64) -> OrderSignal {
476        OrderSignal::PartialFill {
477            filled: Qty::from(filled),
478            left: Qty::from(left),
479        }
480    }
481
482    #[test]
483    fn news_that_is_faster_than_the_answer_is_taken() {
484        // A fill, a partial fill and a cancel can come before the answer to the place command.
485        let mut fsm = OrderFsm::new(cid(1), now());
486        assert!(matches!(
487            fsm.apply(OrderSignal::Filled, now()),
488            Ok(Some(OrderState::Filled { .. }))
489        ));
490        let mut fsm = OrderFsm::new(cid(2), now());
491        assert!(matches!(
492            fsm.apply(partial(1, 2), now()),
493            Ok(Some(OrderState::PartiallyFilled { .. }))
494        ));
495        let mut fsm = OrderFsm::new(cid(3), now());
496        assert!(matches!(
497            fsm.apply(OrderSignal::Cancelled, now()),
498            Ok(Some(OrderState::Cancelled { .. }))
499        ));
500        // And the doubt of an Unknown order is settled by a fill or a partial fill too.
501        let mut fsm = OrderFsm::from_state(OrderState::Unknown {
502            cid: cid(4),
503            since: now(),
504        });
505        assert!(matches!(
506            fsm.apply(partial(1, 2), now()),
507            Ok(Some(OrderState::PartiallyFilled { .. }))
508        ));
509    }
510
511    #[test]
512    fn a_late_answer_changes_nothing() {
513        // The answer to the place command comes after the fill: the order stays filled.
514        let mut fsm = OrderFsm::new(cid(1), now());
515        fsm.apply(OrderSignal::Filled, now()).unwrap();
516        for late in [
517            ack("42"),
518            partial(1, 1),
519            OrderSignal::Timeout,
520            OrderSignal::PlaceSent,
521        ] {
522            assert_eq!(fsm.apply(late, now()), Ok(None));
523        }
524        assert!(matches!(fsm.state(), OrderState::Filled { .. }));
525        // The same final state told twice is not an error either.
526        assert_eq!(fsm.apply(OrderSignal::Filled, now()), Ok(None));
527        let mut cancelled = OrderFsm::from_state(OrderState::Cancelled { cid: cid(2) });
528        assert_eq!(cancelled.apply(OrderSignal::Cancelled, now()), Ok(None));
529        let mut rejected = OrderFsm::from_state(OrderState::Rejected {
530            cid: cid(3),
531            reason: "no".into(),
532        });
533        assert_eq!(
534            rejected.apply(
535                OrderSignal::Rejected {
536                    reason: "no".into()
537                },
538                now()
539            ),
540            Ok(None)
541        );
542        // A repeated ack of a working order, with the order partly filled.
543        let mut working = OrderFsm::from_state(OrderState::Acked {
544            cid: cid(4),
545            broker_id: BrokerOrderId::new("1"),
546        });
547        assert_eq!(working.apply(ack("1"), now()), Ok(None));
548        let mut part = OrderFsm::from_state(OrderState::PartiallyFilled {
549            cid: cid(5),
550            filled: Qty::from(1),
551            left: Qty::from(1),
552        });
553        assert_eq!(part.apply(ack("1"), now()), Ok(None));
554    }
555
556    #[test]
557    fn an_older_snapshot_does_not_take_a_fill_back() {
558        let mut fsm = OrderFsm::from_state(OrderState::PartiallyFilled {
559            cid: cid(1),
560            filled: Qty::from(5),
561            left: Qty::from(5),
562        });
563        // A snapshot with less filled than known arrives late.
564        assert_eq!(fsm.apply(partial(3, 7), now()), Ok(None));
565        assert!(matches!(
566            fsm.state(),
567            OrderState::PartiallyFilled { filled, .. } if *filled == Qty::from(5)
568        ));
569        // More filled moves it on.
570        assert!(matches!(fsm.apply(partial(6, 4), now()), Ok(Some(_))));
571    }
572
573    #[test]
574    fn contradicting_news_is_an_error_and_leaves_the_state() {
575        let mut fsm = OrderFsm::from_state(OrderState::Filled { cid: cid(1) });
576        assert_eq!(
577            fsm.apply(OrderSignal::Cancelled, now()),
578            Err(FsmError::InvalidTransition {
579                from: "Filled",
580                signal: "Cancelled"
581            })
582        );
583        assert!(matches!(fsm.state(), OrderState::Filled { .. }));
584        let mut cancelled = OrderFsm::from_state(OrderState::Cancelled { cid: cid(2) });
585        assert!(cancelled.apply(OrderSignal::Filled, now()).is_err());
586    }
587
588    #[test]
589    fn the_registry_forgets_only_finished_orders() {
590        let mut reg = OrderRegistry::new();
591        let t0 = Instant::now();
592        reg.register_place(cid(1), t0);
593        reg.register_place(cid(2), t0);
594        reg.apply(cid(2), OrderSignal::Filled, t0).unwrap();
595        assert_eq!(reg.len(), 2);
596        assert!(!reg.is_empty());
597        assert!(!reg.forget_finished(cid(1)), "a live order stays");
598        assert!(!reg.forget_finished(cid(9)), "an unknown cid is left alone");
599        assert!(reg.forget_finished(cid(2)));
600        assert_eq!(reg.len(), 1);
601        assert!(reg.get(cid(2)).is_none());
602    }
603}