Skip to main content

rustrade_execution/
trade.rs

1use crate::order::id::{OrderId, StrategyId};
2use chrono::{DateTime, Utc};
3use derive_more::{Constructor, From};
4use rust_decimal::Decimal;
5use rustrade_instrument::{Side, asset::QuoteAsset};
6use serde::{Deserialize, Serialize};
7use smol_str::SmolStr;
8use std::fmt::{Display, Formatter};
9
10/// A venue's own identifier for one trade, carried through opaquely.
11///
12/// # Uniqueness is the venue's to define, and is often narrower than global
13///
14/// This library does not mint these — it passes through whatever the venue said, so what an id
15/// distinguishes is whatever that venue distinguishes. Binance's are **per symbol**, which is why
16/// this crate's own deduplication keys on the instrument alongside the id rather than on the id
17/// alone; Hyperliquid reports a transaction hash that can cover several matches at once. A
18/// consumer that needs a key should use `(exchange, instrument, TradeId)` and not assume any part
19/// of it is redundant.
20///
21/// [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) mints ids unique within one venue
22/// instance for its lifetime — and, because each venue counts from zero, **not** across the
23/// several a multi-exchange backtest holds. That is the same scoping a real venue gives, for the
24/// same reason.
25///
26/// # `Ord` is byte order, not time order
27///
28/// The derived comparison is lexicographic over the underlying string, so `"10" < "9"`. It exists
29/// so this type can key a map or a set; it carries no chronological meaning and is not a stand-in
30/// for one. Order trades by [`Trade::time_exchange`].
31///
32/// # Finding the order behind a trade
33///
34/// Use [`Trade::order_id`], which is typed. Do **not** parse this id: one order can print more
35/// than once, several venues embed structure of their own here (IBKR's `execution_id` is
36/// dot-separated, Alpaca's can be a UUID), and no format is common to them.
37#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, From)]
38pub struct TradeId<T = SmolStr>(pub T);
39
40impl<T> Display for TradeId<T>
41where
42    T: Display,
43{
44    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
45        Display::fmt(&self.0, f)
46    }
47}
48
49impl TradeId {
50    pub fn new<S: AsRef<str>>(id: S) -> Self {
51        Self(SmolStr::new(id))
52    }
53}
54
55#[derive(
56    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
57)]
58pub struct Trade<AssetKey, InstrumentKey> {
59    pub id: TradeId,
60    pub order_id: OrderId,
61    pub instrument: InstrumentKey,
62    pub strategy: StrategyId,
63    pub time_exchange: DateTime<Utc>,
64    pub side: Side,
65    pub price: Decimal,
66    /// The size of *this* execution.
67    pub quantity: Decimal,
68    /// The order's cumulative filled quantity as of this execution, as the venue reported it --
69    /// **not** the size of this execution, which is [`Trade::quantity`].
70    ///
71    /// A fill carries two facts: that an execution happened, and what it left the order at. A
72    /// consumer that tracks order state needs both, and they arrive together or not at all. Where
73    /// the venue supplies the second, carrying it here lets the order be advanced from the fill
74    /// itself rather than from a separate message that may never come.
75    ///
76    /// Advancing from a cumulative is idempotent: re-applying the same fill, or applying two out
77    /// of order, cannot double-count, because the receiver takes the greater of what it holds and
78    /// what is reported rather than adding to a running total.
79    ///
80    /// `None` means the venue's fill payload does not carry it, and the order's state must be
81    /// learned some other way -- an order snapshot, or a reconciliation fetch. It is not a claim
82    /// that nothing is filled. Known producers, as of writing:
83    ///
84    /// | Source | Reports it |
85    /// |---|---|
86    /// | Binance Spot / Margin WebSocket `executionReport` | yes (`z`) |
87    /// | Binance Spot / Margin fill recovery after a disconnect | yes, rebuilt from the order's executions; `None` where that lookup failed or ran out of time |
88    /// | Binance Spot / Margin `fetch_trades` (`myTrades` REST) | no |
89    /// | Alpaca WebSocket `trade_updates` | yes (`order.filled_qty`) |
90    /// | Alpaca account-activities REST | yes (`cum_qty`) |
91    /// | Interactive Brokers `ExecutionData` | yes (`cumulative_quantity`) |
92    /// | Hyperliquid `userFills` | no |
93    /// | `SimulatedVenue` | yes |
94    ///
95    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
96    /// `None`.
97    #[serde(default)]
98    pub order_filled_quantity: Option<Decimal>,
99    pub fees: AssetFees<AssetKey>,
100}
101
102impl<AssetKey, InstrumentKey> Trade<AssetKey, InstrumentKey> {
103    pub fn value_quote(&self) -> Decimal {
104        self.price * self.quantity.abs()
105    }
106}
107
108/// What one [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades) call
109/// read of the span it was asked for.
110///
111/// A venue reads a span in bounded calls, so one call may stop before the span's end. `resume`
112/// says whether this one did:
113/// - `None`: every fill in the span the venue still holds is in `trades`.
114/// - `Some(t)`: the call stopped at its bound, and fills from `t` on may be missing from
115///   `trades`. Read on from `start = t` with the same end. That read can return again fills this
116///   one returned at or after `t`, so match them by instrument and [`TradeId`].
117///
118/// `trades` can be empty while `resume` is `Some`: a venue that reads every instrument's fills
119/// and filters them afterwards can spend a whole call on other instruments'. A caller that takes
120/// `trades` without reading on from `resume` can miss fills.
121#[non_exhaustive]
122#[must_use = "`resume` says whether the span was read to its end"]
123#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
124pub struct TradesRead<AssetKey, InstrumentKey> {
125    /// The fills read, in the span and for the requested instruments.
126    pub trades: Vec<Trade<AssetKey, InstrumentKey>>,
127    /// Where to read on from, or `None` when the read reached the span's end.
128    pub resume: Option<DateTime<Utc>>,
129}
130
131impl<AssetKey, InstrumentKey> TradesRead<AssetKey, InstrumentKey> {
132    /// A read that reached the span's end.
133    pub fn complete(trades: Vec<Trade<AssetKey, InstrumentKey>>) -> Self {
134        Self {
135            trades,
136            resume: None,
137        }
138    }
139}
140
141impl<AssetKey, InstrumentKey> Display for Trade<AssetKey, InstrumentKey>
142where
143    AssetKey: Display,
144    InstrumentKey: Display,
145{
146    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
147        write!(
148            f,
149            "{{ instrument: {}, side: {}, price: {}, quantity: {}, time: {} }}",
150            self.instrument, self.side, self.price, self.quantity, self.time_exchange
151        )
152    }
153}
154
155/// A venue's notice that a trade it reported earlier was busted or corrected.
156///
157/// Sent as [`AccountEventKind::TradeAmended`](crate::AccountEventKind::TradeAmended). The
158/// [`Trade`] it amends was delivered as reported and is not withdrawn: positions, PnL and fees
159/// built on it stay wrong until the consumer applies this. Find it by instrument and
160/// [`original`](Self::original), reverse it, and apply the [`kind`](Self::kind)'s replacement, if
161/// any. The library does not act on it beyond reporting it, and the engine only logs it.
162///
163/// A replacement can itself be amended later. That amendment names the replacement's id as its
164/// `original`, so follow amendments by id, one at a time.
165///
166/// # Known limitations
167///
168/// - An order's cumulative filled quantity is not lowered by a bust:
169///   [`Open::is_superseded_by`](crate::order::state::Open::is_superseded_by) refuses a lower
170///   cumulative, so the order stays on what it reported before.
171/// - Alpaca's fill recovery after a reconnect reads fills only, so an Alpaca amendment sent while
172///   the stream was disconnected is not reported. IBKR's recovery reads corrections too.
173///
174/// # Delivery
175///
176/// Apply an amendment idempotently: reverse a given `original` once, and apply a replacement
177/// once by its [`TradeId`]. Whether a venue can send one amendment twice, such as around a
178/// reconnect, is not known for every producer. The library delivers each IBKR correction once
179/// per account stream, but does not deduplicate Alpaca's amendments.
180///
181/// Known producers, as of writing:
182/// - Alpaca's `trade_updates` `trade_bust` and `trade_correct`;
183/// - IBKR's corrected executions, as [`Corrected`](TradeAmendmentKind::Corrected) only. IB
184///   documents no busts.
185#[non_exhaustive]
186#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
187pub struct TradeAmendment<AssetKey, InstrumentKey> {
188    /// The instrument of the trade amended.
189    pub instrument: InstrumentKey,
190    /// The order whose trade was amended.
191    pub order_id: OrderId,
192    /// When the venue amended the trade, or when it was received where the venue does not say.
193    pub time_exchange: DateTime<Utc>,
194    /// The [`TradeId`] of the trade amended, as the venue named it. `None` when the venue's notice
195    /// did not name it: match it to a trade of [`order_id`](Self::order_id) yourself, or
196    /// reconcile with
197    /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades).
198    pub original: Option<TradeId>,
199    /// What the venue did to the trade.
200    pub kind: TradeAmendmentKind<AssetKey, InstrumentKey>,
201}
202
203/// What a [`TradeAmendment`] did to the trade.
204#[non_exhaustive]
205#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)]
206pub enum TradeAmendmentKind<AssetKey, InstrumentKey> {
207    /// The trade was cancelled: it did not happen.
208    Busted {
209        /// The quantity busted, as a magnitude, when the venue said. A venue that reports it as
210        /// a negative reversal is read as its absolute value.
211        quantity: Option<Decimal>,
212    },
213    /// The trade was replaced, in full.
214    Corrected {
215        /// The trade as corrected: its price and quantity are the corrected values, not
216        /// differences from the original's. It has its own [`TradeId`], which a later amendment
217        /// of it names.
218        replacement: Trade<AssetKey, InstrumentKey>,
219    },
220    /// The trade was corrected, but the venue's notice lacked what a replacement [`Trade`] needs.
221    /// What it did carry is here. Reconcile the trade with
222    /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades).
223    CorrectedUnresolved {
224        /// The id of the replacement, which a later amendment of it names.
225        id: Option<TradeId>,
226        /// The corrected price, if the notice had one.
227        price: Option<Decimal>,
228        /// The corrected quantity, if the notice had one.
229        quantity: Option<Decimal>,
230    },
231}
232
233#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
234pub struct AssetFees<AssetKey> {
235    pub asset: AssetKey,
236    pub fees: Decimal,
237    /// Fee value in quote currency when computable.
238    /// - `Some(fees)` if fee asset == quote asset
239    /// - `Some(fees * price)` if fee asset == base asset (computed by indexer)
240    /// - `None` if fee asset is third-party (e.g., BNB) — requires external price data
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub fees_quote: Option<Decimal>,
243}
244
245impl<AssetKey> AssetFees<AssetKey> {
246    pub fn new(asset: AssetKey, fees: Decimal, fees_quote: Option<Decimal>) -> Self {
247        Self {
248            asset,
249            fees,
250            fees_quote,
251        }
252    }
253}
254
255impl AssetFees<QuoteAsset> {
256    /// Construct fees already denominated in quote asset.
257    /// Sets `fees_quote = Some(fees)` since no conversion needed.
258    pub fn quote_fees(fees: Decimal) -> Self {
259        Self {
260            asset: QuoteAsset,
261            fees,
262            fees_quote: Some(fees),
263        }
264    }
265}
266
267impl Default for AssetFees<QuoteAsset> {
268    fn default() -> Self {
269        Self {
270            asset: QuoteAsset,
271            fees: Decimal::ZERO,
272            fees_quote: Some(Decimal::ZERO),
273        }
274    }
275}
276
277impl<AssetKey> Default for AssetFees<Option<AssetKey>> {
278    fn default() -> Self {
279        Self {
280            asset: None,
281            fees: Decimal::ZERO,
282            fees_quote: None,
283        }
284    }
285}