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}