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.
51 pub attempts: u32,
52 /// Why the last read failed.
53 pub reason: FillRecoveryFailure,
54}
55
56/// Which instruments a [`FillRecoveryGap`] covers.
57#[non_exhaustive]
58#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
59pub enum FillRecoveryScope<InstrumentKey> {
60 /// These instruments. A venue never sends an empty list; note that
61 /// [`instrument_filter`](Self::instrument_filter) would pass one on as every instrument.
62 Instruments(Vec<InstrumentKey>),
63 /// Every instrument on the account: the read covered all of them, as Alpaca's does for a
64 /// stream opened without an instrument list.
65 AllInstruments,
66}
67
68impl<InstrumentKey> FillRecoveryScope<InstrumentKey> {
69 /// The instruments to pass to
70 /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades), in its
71 /// convention: an empty slice means every instrument.
72 pub fn instrument_filter(&self) -> &[InstrumentKey] {
73 match self {
74 Self::Instruments(instruments) => instruments,
75 Self::AllInstruments => &[],
76 }
77 }
78}
79
80/// Why fill recovery's last read of a [`FillRecoveryGap`] failed.
81#[non_exhaustive]
82#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, Error)]
83pub enum FillRecoveryFailure {
84 /// The venue request failed.
85 #[error("request failed: {0}")]
86 Request(String),
87
88 /// The read did not finish within the recovery's time budget.
89 #[error("timed out after {timeout_secs}s")]
90 TimedOut {
91 /// The time budget, in seconds.
92 timeout_secs: u64,
93 },
94
95 /// The read stopped at the venue integration's cap on how many fills one read returns. The
96 /// fills it read were delivered, and the span starts at the last of them (at its millisecond,
97 /// on Alpaca), so fills sharing its time that the read cut off are inside it.
98 #[error("truncated after {fills_read} fills")]
99 Truncated {
100 /// How many fills the read returned before it stopped, counted across the account,
101 /// before any filter by instrument.
102 fills_read: usize,
103 },
104
105 /// The venue no longer holds the fills before `held_since`, so the span's part before it
106 /// could not be read. It is the gap: its `end` is the moment before `held_since`.
107 #[error("the venue holds fills only from {held_since}")]
108 VenueRetention {
109 /// The earliest moment the venue still holds fills from.
110 held_since: DateTime<Utc>,
111 },
112
113 /// The span reaches back further than the recovery's configured maximum span. The gap is the
114 /// part before that maximum, which was not read.
115 #[error("older than the maximum span of {max_span_secs}s")]
116 SpanLimit {
117 /// The maximum span, in seconds.
118 max_span_secs: u64,
119 },
120
121 /// The read stopped at the recovery's configured maximum number of calls. The gap is the part
122 /// not yet read.
123 #[error("stopped after {max_pages} reads")]
124 PageLimit {
125 /// The maximum number of calls.
126 max_pages: u32,
127 },
128}