rustrade_execution/fill_recovery.rs
1//! What an account stream reports when its fill recovery gives up on a span of fills.
2//!
3//! Venues that reconnect internally (Binance Spot and Margin, Alpaca) read the fills they missed
4//! while disconnected by REST after each reconnect, and so does the execution manager when it
5//! reopens an account stream that ended. When that read fails for good, or the venue no longer
6//! holds the fills, they never reach the consumer, and the stream says so in-band with
7//! [`AccountEventKind::FillRecoveryGaveUp`](crate::AccountEventKind::FillRecoveryGaveUp), carrying
8//! a [`FillRecoveryGap`].
9
10use chrono::{DateTime, Utc};
11use derive_more::Constructor;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15/// A span of fills an account stream's recovery gave up on: fills in it may not have been
16/// delivered.
17///
18/// Sent as [`AccountEventKind::FillRecoveryGaveUp`](crate::AccountEventKind::FillRecoveryGaveUp),
19/// once per failed read. The library does not act on it beyond reporting it. What to do is the
20/// consumer's policy: reconcile, alert, or halt.
21///
22/// # Reconciling
23///
24/// Read the span with
25/// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades), passing
26/// `start`, `end` and [`FillRecoveryScope::instrument_filter`], and read on from each call's
27/// [`resume`](crate::trade::TradesRead::resume) until it is `None`: a busy span can take more
28/// than one call.
29///
30/// The span can overlap fills that were delivered, before the read failed or live around the
31/// reconnect, so match what the reads return against the fills already seen by instrument and
32/// [`TradeId`](crate::trade::TradeId). A fill has one `TradeId` whether the stream or
33/// `fetch_trades` delivers it.
34///
35/// # Delivery
36///
37/// The event is sent on the account stream like any other, so it is lost if the consumer has
38/// dropped the stream. On a [`Truncated`](FillRecoveryFailure::Truncated) read, the fills that
39/// were read are sent first and the event after them.
40#[non_exhaustive]
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, Constructor)]
42pub struct FillRecoveryGap<InstrumentKey> {
43 /// Which instruments' fills the span covers.
44 pub scope: FillRecoveryScope<InstrumentKey>,
45 /// The first moment of the span, inclusive.
46 pub start: DateTime<Utc>,
47 /// The last moment of the span, inclusive.
48 pub end: DateTime<Utc>,
49 /// How many times the span was read before it was given up: 1 when the venue does not retry
50 /// it. Retries within one read, such as after a rate limit, are not counted. For a
51 /// [`Processing`](FillRecoveryFailure::Processing) gap, how many times the client tried to
52 /// process the fill.
53 pub attempts: u32,
54 /// Why the last read failed.
55 pub reason: FillRecoveryFailure,
56}
57
58/// Which instruments a [`FillRecoveryGap`] covers.
59#[non_exhaustive]
60#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
61pub enum FillRecoveryScope<InstrumentKey> {
62 /// These instruments. A venue never sends an empty list; note that
63 /// [`instrument_filter`](Self::instrument_filter) would pass one on as every instrument.
64 Instruments(Vec<InstrumentKey>),
65 /// Every instrument on the account: the read covered all of them, as Alpaca's does for a
66 /// stream opened without an instrument list.
67 AllInstruments,
68}
69
70impl<InstrumentKey> FillRecoveryScope<InstrumentKey> {
71 /// The instruments to pass to
72 /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades), in its
73 /// convention: an empty slice means every instrument.
74 pub fn instrument_filter(&self) -> &[InstrumentKey] {
75 match self {
76 Self::Instruments(instruments) => instruments,
77 Self::AllInstruments => &[],
78 }
79 }
80}
81
82/// Why fill recovery's last read of a [`FillRecoveryGap`] failed.
83#[non_exhaustive]
84#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, Error)]
85pub enum FillRecoveryFailure {
86 /// The venue request failed.
87 #[error("request failed: {0}")]
88 Request(String),
89
90 /// The read did not finish within the recovery's time budget.
91 #[error("timed out after {timeout_secs}s")]
92 TimedOut {
93 /// The time budget, in seconds.
94 timeout_secs: u64,
95 },
96
97 /// The read stopped at the venue integration's cap on how many fills one read returns. The
98 /// fills it read were delivered, and the span starts at the last of them (at its millisecond,
99 /// on Alpaca), so fills sharing its time that the read cut off are inside it.
100 #[error("truncated after {fills_read} fills")]
101 Truncated {
102 /// How many fills the read returned before it stopped, counted across the account,
103 /// before any filter by instrument.
104 fills_read: usize,
105 },
106
107 /// The venue no longer holds the fills before `held_since`, so the span's part before it
108 /// could not be read. It is the gap: its `end` is the moment before `held_since`.
109 #[error("the venue holds fills only from {held_since}")]
110 VenueRetention {
111 /// The earliest moment the venue still holds fills from.
112 held_since: DateTime<Utc>,
113 },
114
115 /// The span reaches back further than the recovery's configured maximum span. The gap is the
116 /// part before that maximum, which was not read.
117 #[error("older than the maximum span of {max_span_secs}s")]
118 SpanLimit {
119 /// The maximum span, in seconds.
120 max_span_secs: u64,
121 },
122
123 /// The read stopped at the recovery's configured maximum number of calls. The gap is the part
124 /// not yet read.
125 #[error("stopped after {max_pages} reads")]
126 PageLimit {
127 /// The maximum number of calls.
128 max_pages: u32,
129 },
130
131 /// A fill was delivered, but a client's
132 /// [`process_account_events`](crate::client::ExecutionClient::process_account_events) could not
133 /// process it and gave up on it, so it did not reach the consumer. The gap's scope is the
134 /// fill's instrument, its span the fill's time, and its `attempts` the client's tries.
135 #[error("the client could not process a fill: {0}")]
136 Processing(String),
137}