Skip to main content

brokapi_runtime/
orders.rs

1//! In-memory order tracking.
2
3use std::collections::{HashMap, VecDeque};
4use std::time::{Duration, Instant};
5
6use brokapi_core::{ClientOrderId, OrderState};
7use brokapi_sansio::{FsmError, OrderRegistry, OrderSignal};
8use tracing::warn;
9
10/// Runtime order book: one state machine per client order id, a timeout sweep, and a guard that
11/// never lets the same place command go out twice.
12///
13/// The rule: a place command that got no answer is never repeated. A retry could create a second
14/// order at the broker. Instead the order goes to [`OrderState::Unknown`] and is settled by reading
15/// the broker (reconcile or order state). [`try_place_once`](Self::try_place_once) enforces the
16/// "once", and [`sweep_timeouts`](Self::sweep_timeouts) turns silent in-flight orders into
17/// `Unknown`.
18///
19/// # Examples
20///
21/// ```
22/// use std::time::{Duration, Instant};
23/// use brokapi_core::{BrokerOrderId, ClientOrderId, OrderState};
24/// use brokapi_runtime::OrderRuntime;
25/// use brokapi_sansio::OrderSignal;
26///
27/// let mut orders = OrderRuntime::with_timeout(Duration::from_secs(30));
28/// let t0 = Instant::now();
29/// let cid = ClientOrderId::new_v4();
30///
31/// // First send of this cid is allowed. A second send of the same cid is not.
32/// assert!(orders.try_place_once(cid));
33/// orders.register_place(cid, t0);
34/// assert!(!orders.try_place_once(cid));
35///
36/// // No answer within the timeout: the outcome is unknown, not "failed".
37/// let changed = orders.sweep_timeouts(t0 + Duration::from_secs(31));
38/// assert!(matches!(changed[0].1, OrderState::Unknown { .. }));
39///
40/// // A late ack from the stream or from a reconcile settles it.
41/// let acked = orders.apply_signal(cid, OrderSignal::Ack { broker_id: BrokerOrderId::new("42") }, t0);
42/// assert!(matches!(acked, Some(OrderState::Acked { .. })));
43/// ```
44///
45/// # Memory
46///
47/// A finished order (filled, cancelled, rejected) is kept for a window and then forgotten, so a bot that
48/// trades for months does not grow without end. The window is [`RETENTION`] (24 hours) and at most
49/// [`RETAINED_FINISHED`] finished orders, whichever ends first (see
50/// [`with_retention`](OrderRuntime::with_retention)). **The guarantee "a cid is placed once" holds inside
51/// the window.** A cid older than the window is no longer remembered here, so a place with it would pass
52/// the local check (the broker may still refuse it: T-Bank does, by its idempotency key). Never reuse a
53/// cid: make a new one for every order.
54#[derive(Debug)]
55pub struct OrderRuntime {
56    registry: OrderRegistry,
57    command_timeout: Duration,
58    place_attempts: HashMap<ClientOrderId, u8>,
59    /// Orders in the order they finished, oldest first.
60    finished: VecDeque<(Instant, ClientOrderId)>,
61    retention: Duration,
62    max_finished: usize,
63}
64
65/// How long a finished order is remembered by [`OrderRuntime`] (24 hours).
66pub const RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
67
68/// How many finished orders [`OrderRuntime`] remembers at most (100 000).
69pub const RETAINED_FINISHED: usize = 100_000;
70
71impl Default for OrderRuntime {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl OrderRuntime {
78    /// Creates an empty order book with a command timeout of 30 seconds and the default memory window
79    /// ([`RETENTION`], [`RETAINED_FINISHED`]).
80    pub fn new() -> Self {
81        Self {
82            registry: OrderRegistry::new(),
83            command_timeout: Duration::from_secs(30),
84            place_attempts: HashMap::new(),
85            finished: VecDeque::new(),
86            retention: RETENTION,
87            max_finished: RETAINED_FINISHED,
88        }
89    }
90
91    /// Creates an empty order book with the given command timeout, the age after which an in-flight
92    /// order is considered unanswered by [`sweep_timeouts`](Self::sweep_timeouts).
93    pub fn with_timeout(command_timeout: Duration) -> Self {
94        Self {
95            command_timeout,
96            ..Self::new()
97        }
98    }
99
100    /// Changes the command timeout of [`sweep_timeouts`](Self::sweep_timeouts). A connector sets it a
101    /// little above its request timeout, so that an order is not given up while its call is still running.
102    pub fn set_command_timeout(&mut self, command_timeout: Duration) {
103        self.command_timeout = command_timeout;
104    }
105
106    /// Sets the memory window of finished orders: they are forgotten after `window`, or earlier when
107    /// more than `max_finished` are kept (the oldest go first). See the type docs for what this means for
108    /// the "a cid is placed once" guarantee.
109    ///
110    /// # Examples
111    ///
112    /// ```
113    /// use std::time::{Duration, Instant};
114    /// use brokapi_core::ClientOrderId;
115    /// use brokapi_runtime::OrderRuntime;
116    /// use brokapi_sansio::OrderSignal;
117    ///
118    /// let mut orders = OrderRuntime::new().with_retention(Duration::from_secs(60), 1000);
119    /// let t0 = Instant::now();
120    /// let cid = ClientOrderId::new_v4();
121    /// assert!(orders.try_place_once(cid));
122    /// orders.register_place(cid, t0);
123    /// orders.apply_signal(cid, OrderSignal::Rejected { reason: "no".into() }, t0);
124    ///
125    /// // Inside the window the cid is still refused ...
126    /// orders.prune(t0 + Duration::from_secs(30));
127    /// assert!(!orders.try_place_once(cid));
128    /// // ... after it the order is forgotten.
129    /// orders.prune(t0 + Duration::from_secs(61));
130    /// assert!(orders.state(cid).is_none());
131    /// assert!(orders.try_place_once(cid));
132    /// ```
133    #[must_use]
134    pub fn with_retention(mut self, window: Duration, max_finished: usize) -> Self {
135        self.retention = window;
136        self.max_finished = max_finished;
137        self
138    }
139
140    /// Forgets the finished orders that are older than the window, or beyond the count limit, as of `now`.
141    /// Called by [`apply_signal`](Self::apply_signal) itself, so a caller rarely needs it.
142    pub fn prune(&mut self, now: Instant) {
143        while let Some(&(at, cid)) = self.finished.front() {
144            let expired = now.saturating_duration_since(at) >= self.retention;
145            if !(expired || self.finished.len() > self.max_finished) {
146                break;
147            }
148            self.finished.pop_front();
149            // A cid that was registered again after it finished is alive: leave it.
150            if self.registry.forget_finished(cid) {
151                self.place_attempts.remove(&cid);
152            }
153        }
154    }
155
156    /// How many orders are tracked now, in any state. For tests and metrics.
157    pub fn tracked(&self) -> usize {
158        self.registry.len()
159    }
160
161    /// How many cids are remembered as used for a place. For tests and metrics.
162    pub fn remembered_places(&self) -> usize {
163        self.place_attempts.len()
164    }
165
166    /// How many orders are `InFlight` now: sent, with no answer yet.
167    pub fn in_flight_count(&self) -> usize {
168        self.registry.in_flight_cids().len()
169    }
170
171    /// Starts tracking a place command that is being sent at `now`, in state `InFlight`.
172    ///
173    /// Records the first (and only allowed) attempt for `cid`. Registering a cid again restarts its
174    /// state machine from `InFlight`.
175    pub fn register_place(&mut self, cid: ClientOrderId, now: Instant) {
176        self.registry.register_place(cid, now);
177        self.place_attempts.insert(cid, 1);
178    }
179
180    /// Starts tracking a cid known only from a cancel, if it is not tracked yet.
181    ///
182    /// An order placed by an earlier run is not in memory. A cid that is already tracked keeps its
183    /// state. A new one starts as `InFlight`, and does not count as a place attempt.
184    pub fn register_cancel(&mut self, cid: ClientOrderId, now: Instant) {
185        if self.registry.get(cid).is_none() {
186            self.registry.register_place(cid, now);
187        }
188    }
189
190    /// Returns `true` and records the attempt when `cid` has never been used for a place, `false`
191    /// when it has.
192    ///
193    /// Why: the library must not retry a place on its own. Compare
194    /// [`try_place_once`](Self::try_place_once), which does the same.
195    pub fn may_send_place(&mut self, cid: ClientOrderId) -> bool {
196        self.try_place_once(cid)
197    }
198
199    /// Returns `true` only on the first place attempt for this cid, and records that attempt.
200    ///
201    /// Call it before sending. The attempt is recorded even when the send then fails locally (for
202    /// example a rate-limit refusal), so the same cid is never used twice. To try again, make a new
203    /// cid. After [`register_place`](Self::register_place) the answer is always `false`. The record is
204    /// kept for the memory window of the type, see there.
205    ///
206    /// # Examples
207    ///
208    /// ```
209    /// use brokapi_core::ClientOrderId;
210    /// use brokapi_runtime::OrderRuntime;
211    ///
212    /// let mut orders = OrderRuntime::new();
213    /// let cid = ClientOrderId::new_v4();
214    /// assert!(orders.try_place_once(cid));
215    /// assert!(!orders.try_place_once(cid));
216    /// assert!(orders.try_place_once(ClientOrderId::new_v4()));
217    /// ```
218    pub fn try_place_once(&mut self, cid: ClientOrderId) -> bool {
219        match self.place_attempts.get(&cid) {
220            None => {
221                self.place_attempts.insert(cid, 1);
222                true
223            }
224            Some(_) => false,
225        }
226    }
227
228    /// Feeds a signal (ack, fill, cancel, reject, timeout) into the order's state machine.
229    ///
230    /// Returns the new state when the state changed. Returns `None` when nothing changed, when
231    /// the cid is not tracked, and when the signal is not valid in the current state (for example
232    /// a cancel after `Filled`). An invalid signal is logged at warn level. A caller that needs to tell
233    /// these apart uses [`try_apply_signal`](Self::try_apply_signal).
234    pub fn apply_signal(
235        &mut self,
236        cid: ClientOrderId,
237        signal: OrderSignal,
238        now: Instant,
239    ) -> Option<OrderState> {
240        match self.try_apply_signal(cid, signal, now) {
241            Ok(changed) => changed,
242            // A cid this process does not track is expected for foreign orders.
243            Err(FsmError::UnknownOrder(_)) => None,
244            Err(e) => {
245                warn!(%cid, error = ?e, "an order signal contradicts the known state, ignored");
246                None
247            }
248        }
249    }
250
251    /// Like [`apply_signal`](Self::apply_signal) but tells the reason when the signal is not applied.
252    ///
253    /// # Errors
254    ///
255    /// [`FsmError::UnknownOrder`] for a cid that is not tracked, [`FsmError::InvalidTransition`] for a
256    /// signal that contradicts the state (a cancel of a filled order). The state is left as it was.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use std::time::Instant;
262    /// use brokapi_core::ClientOrderId;
263    /// use brokapi_runtime::OrderRuntime;
264    /// use brokapi_sansio::{FsmError, OrderSignal};
265    ///
266    /// let mut orders = OrderRuntime::new();
267    /// let t0 = Instant::now();
268    /// let cid = ClientOrderId::new_v4();
269    /// assert!(matches!(
270    ///     orders.try_apply_signal(cid, OrderSignal::Filled, t0),
271    ///     Err(FsmError::UnknownOrder(_))
272    /// ));
273    ///
274    /// // A fill that is faster than the answer to the place command is taken.
275    /// orders.register_place(cid, t0);
276    /// assert!(orders.try_apply_signal(cid, OrderSignal::Filled, t0).unwrap().is_some());
277    /// // A cancel of a filled order contradicts it.
278    /// assert!(orders.try_apply_signal(cid, OrderSignal::Cancelled, t0).is_err());
279    /// ```
280    pub fn try_apply_signal(
281        &mut self,
282        cid: ClientOrderId,
283        signal: OrderSignal,
284        now: Instant,
285    ) -> Result<Option<OrderState>, FsmError> {
286        let changed = self.registry.apply(cid, signal, now)?;
287        if let Some(
288            OrderState::Filled { .. } | OrderState::Cancelled { .. } | OrderState::Rejected { .. },
289        ) = &changed
290        {
291            self.finished.push_back((now, cid));
292            self.prune(now);
293        }
294        Ok(changed)
295    }
296
297    /// Moves every `InFlight` order older than the command timeout to `Unknown`.
298    ///
299    /// Returns the orders that changed, with their new state. It does not cancel or retry
300    /// anything: the exchange order may well exist.
301    pub fn sweep_timeouts(&mut self, now: Instant) -> Vec<(ClientOrderId, OrderState)> {
302        self.registry.on_global_timeout(now, self.command_timeout)
303    }
304
305    /// The current state of a tracked order, or `None` for an unknown cid.
306    pub fn state(&self, cid: ClientOrderId) -> Option<&OrderState> {
307        self.registry.get(cid).map(|f| f.state())
308    }
309
310    /// Moves every `InFlight` order to `Unknown` now, whatever its age. Used at shutdown.
311    ///
312    /// Returns the cids that changed. Orders at the broker are untouched, only the local wait for
313    /// an answer is given up.
314    pub fn abandon_inflight(&mut self, now: Instant) -> Vec<ClientOrderId> {
315        self.registry
316            .abandon_inflight(now)
317            .into_iter()
318            .map(|(cid, _)| cid)
319            .collect()
320    }
321}
322
323/// A memory window for things that belong to finished orders: the cid maps of a connector.
324///
325/// A connector keeps small maps next to its [`OrderRuntime`] (cid to instrument, broker id to cid, ...).
326/// When an order is final, the connector [`push`](Self::push)es its cid here. Whatever is older than the
327/// window, or beyond the count limit, comes back from [`expired`](Self::expired), and the connector drops
328/// the entries of those cids. So the maps stay as big as a day of trading and not as the whole life of the
329/// process.
330///
331/// # Examples
332///
333/// ```
334/// use std::time::{Duration, Instant};
335/// use brokapi_runtime::FinishedWindow;
336///
337/// let mut window = FinishedWindow::with_limits(Duration::from_secs(60), 100);
338/// let t0 = Instant::now();
339/// window.push(t0, "a");
340/// window.push(t0 + Duration::from_secs(30), "b");
341/// assert!(window.expired(t0 + Duration::from_secs(59)).is_empty());
342/// assert_eq!(window.expired(t0 + Duration::from_secs(61)), vec!["a"]);
343/// assert_eq!(window.len(), 1);
344/// ```
345#[derive(Debug)]
346pub struct FinishedWindow<K> {
347    window: Duration,
348    max: usize,
349    items: VecDeque<(Instant, K)>,
350}
351
352impl<K> Default for FinishedWindow<K> {
353    fn default() -> Self {
354        Self::with_limits(RETENTION, RETAINED_FINISHED)
355    }
356}
357
358impl<K> FinishedWindow<K> {
359    /// A window of [`RETENTION`] and [`RETAINED_FINISHED`].
360    pub fn new() -> Self {
361        Self::default()
362    }
363
364    /// A window that keeps a key for `window`, and at most `max` keys.
365    pub fn with_limits(window: Duration, max: usize) -> Self {
366        Self {
367            window,
368            max,
369            items: VecDeque::new(),
370        }
371    }
372
373    /// Notes that the order `key` finished at `now`.
374    pub fn push(&mut self, now: Instant, key: K) {
375        self.items.push_back((now, key));
376    }
377
378    /// Takes out the keys that are older than the window as of `now`, or beyond the count limit, oldest
379    /// first. The caller drops what it kept for them.
380    pub fn expired(&mut self, now: Instant) -> Vec<K> {
381        let mut out = Vec::new();
382        while let Some((at, _)) = self.items.front() {
383            let old = now.saturating_duration_since(*at) >= self.window;
384            if !(old || self.items.len() > self.max) {
385                break;
386            }
387            if let Some((_, key)) = self.items.pop_front() {
388                out.push(key);
389            }
390        }
391        out
392    }
393
394    /// How many keys are held.
395    pub fn len(&self) -> usize {
396        self.items.len()
397    }
398
399    /// `true` when no key is held.
400    pub fn is_empty(&self) -> bool {
401        self.items.is_empty()
402    }
403}