Skip to main content

rustrade_execution/
trade.rs

1use crate::order::id::{ClientOrderId, OrderId, StrategyId};
2use chrono::{DateTime, Utc};
3use derive_more::{Constructor, From};
4use rust_decimal::Decimal;
5use rustrade_instrument::{
6    Side,
7    asset::QuoteAsset,
8    instrument::{Instrument, kind::InstrumentKind, round_toward_zero_to_step},
9};
10use serde::{Deserialize, Serialize};
11use smol_str::SmolStr;
12use std::fmt::{Display, Formatter};
13
14/// A venue's own identifier for one trade, carried through opaquely.
15///
16/// # Uniqueness is the venue's to define, and is often narrower than global
17///
18/// This library does not mint these — it passes through whatever the venue said, so what an id
19/// distinguishes is whatever that venue distinguishes. Binance's are **per symbol**, which is why
20/// this crate's own deduplication keys on the instrument alongside the id rather than on the id
21/// alone; Hyperliquid reports a transaction hash that can cover several matches at once. A
22/// consumer that needs a key should use `(exchange, instrument, TradeId)` and not assume any part
23/// of it is redundant.
24///
25/// [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) mints ids unique within one venue
26/// instance for its lifetime — and, because each venue counts from zero, **not** across the
27/// several a multi-exchange backtest holds. That is the same scoping a real venue gives, for the
28/// same reason.
29///
30/// # `Ord` is byte order, not time order
31///
32/// The derived comparison is lexicographic over the underlying string, so `"10" < "9"`. It exists
33/// so this type can key a map or a set; it carries no chronological meaning and is not a stand-in
34/// for one. Order trades by [`Trade::time_exchange`].
35///
36/// # Finding the order behind a trade
37///
38/// Use [`Trade::order_id`], which is typed. Do **not** parse this id: one order can print more
39/// than once, several venues embed structure of their own here (IBKR's `execution_id` is
40/// dot-separated, Alpaca's can be a UUID), and no format is common to them.
41#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, From)]
42pub struct TradeId<T = SmolStr>(pub T);
43
44impl<T> Display for TradeId<T>
45where
46    T: Display,
47{
48    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
49        Display::fmt(&self.0, f)
50    }
51}
52
53impl TradeId {
54    pub fn new<S: AsRef<str>>(id: S) -> Self {
55        Self(SmolStr::new(id))
56    }
57}
58
59/// One execution: a fill the venue reported, or one the engine settled itself.
60///
61/// `#[non_exhaustive]`, so a field can be added without breaking a caller. Outside this crate,
62/// build one with [`Trade::new`] and the `with_*` methods, and change a copy by assigning its
63/// public fields.
64#[non_exhaustive]
65#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
66pub struct Trade<AssetKey, InstrumentKey> {
67    pub id: TradeId,
68    pub order_id: OrderId,
69    pub instrument: InstrumentKey,
70    pub strategy: StrategyId,
71    pub time_exchange: DateTime<Utc>,
72    pub side: Side,
73    pub price: Decimal,
74    /// The size of *this* execution.
75    pub quantity: Decimal,
76    /// The order's cumulative filled quantity as of this execution, as the venue reported it --
77    /// **not** the size of this execution, which is [`Trade::quantity`].
78    ///
79    /// A fill carries two facts: that an execution happened, and what it left the order at. A
80    /// consumer that tracks order state needs both, and they arrive together or not at all. Where
81    /// the venue supplies the second, carrying it here lets the order be advanced from the fill
82    /// itself rather than from a separate message that may never come.
83    ///
84    /// Advancing from a cumulative is idempotent: re-applying the same fill, or applying two out
85    /// of order, cannot double-count, because the receiver takes the greater of what it holds and
86    /// what is reported rather than adding to a running total.
87    ///
88    /// `None` means the venue's fill payload does not carry it, and the order's state must be
89    /// learned some other way -- an order snapshot, or a reconciliation fetch. It is not a claim
90    /// that nothing is filled. Known producers, as of writing:
91    ///
92    /// | Source | Reports it |
93    /// |---|---|
94    /// | Binance Spot / Margin WebSocket `executionReport` | yes (`z`) |
95    /// | 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 |
96    /// | Binance Spot / Margin `fetch_trades` (`myTrades` REST) | no |
97    /// | Alpaca WebSocket `trade_updates` | yes (`order.filled_qty`) |
98    /// | Alpaca account-activities REST | yes (`cum_qty`) |
99    /// | Interactive Brokers `ExecutionData` | yes (`cumulative_quantity`) |
100    /// | Hyperliquid `userFills` | no |
101    /// | `SimulatedVenue` | yes |
102    ///
103    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
104    /// `None`.
105    #[serde(default)]
106    pub order_filled_quantity: Option<Decimal>,
107    pub fees: AssetFees<AssetKey>,
108    /// Why the fill happened, as far as its producer can tell. See [`TradeOrigin`] for what each
109    /// venue reports.
110    ///
111    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
112    /// [`TradeOrigin::Order`].
113    #[serde(default)]
114    pub origin: TradeOrigin,
115    /// The side of the position the fill applies to, when the venue names one: [`Side::Buy`] for
116    /// a long, [`Side::Sell`] for a short.
117    ///
118    /// A venue in hedge mode, holding a long and a short in one instrument at once, names the
119    /// position each fill belongs to (Binance USD-M futures' `LONG` and `SHORT`). The engine in
120    /// `OmsMode::Hedging` checks a forced fill, which matches none of its orders, against it: a
121    /// forced fill must reduce the position the venue names. It only ever confirms the side a
122    /// reducing fill already implies, the side opposite its own, and does not choose among
123    /// several of the engine's positions on that side.
124    ///
125    /// It takes part in the derived `Eq`, `Hash` and `Ord`, as every field does: deduplicate fills
126    /// by instrument and [`id`](Self::id), not by the whole `Trade`.
127    /// `None` when the venue names none, as every client in this crate does as of writing: none
128    /// trades a venue in hedge mode.
129    ///
130    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
131    /// `None`.
132    #[serde(default)]
133    pub position_side: Option<Side>,
134    /// The client order id of the order this fill belongs to, as the venue reported it with the
135    /// fill.
136    ///
137    /// The id is chosen before the order is sent, so a consumer can attribute a fill by it at
138    /// once. That includes a fill of an order placed outside the engine on the same account,
139    /// which no order the engine knows matches: [`order_id`](Self::order_id) is the venue's, and
140    /// the fill can arrive before the venue's response to the order names it, or long after, in a
141    /// recovery read. A consumer that trades an account outside the engine can mark those orders'
142    /// client order ids, by a prefix say, and keep their fills from the engine.
143    ///
144    /// `None` means the fill's payload did not carry one. It is not a claim that the order has
145    /// none. Known producers, as of writing:
146    ///
147    /// | Source | Reports it |
148    /// |---|---|
149    /// | Binance Spot / Margin WebSocket `executionReport` | yes (`c`) |
150    /// | Binance Spot / Margin fill recovery after a disconnect | yes, read from each recovered fill's order; `None` where that lookup failed or ran out of time |
151    /// | Binance Spot / Margin `fetch_trades` (`myTrades` REST) | no |
152    /// | Alpaca WebSocket `trade_updates` | yes (`order.client_order_id`) |
153    /// | Alpaca account-activities REST (fill recovery, `fetch_trades`), `fetch_option_events` | no |
154    /// | Interactive Brokers executions | yes, the order reference, which this crate's client sets to the client order id; `None` for an order without one |
155    /// | Hyperliquid `userFills` | yes, the client order id the order's cloid maps to; `None` for an order placed without a cloid |
156    /// | `SimulatedVenue` | yes |
157    /// | The `rustrade` engine's expiry settlement | no |
158    ///
159    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
160    /// `None`.
161    #[serde(default)]
162    pub client_order_id: Option<ClientOrderId>,
163}
164
165impl<AssetKey, InstrumentKey> Trade<AssetKey, InstrumentKey> {
166    /// A trade of [`TradeOrigin::Order`]. Set another origin with
167    /// [`with_origin`](Self::with_origin).
168    #[allow(clippy::too_many_arguments)] // One argument per field a fill always has.
169    pub fn new(
170        id: TradeId,
171        order_id: OrderId,
172        instrument: InstrumentKey,
173        strategy: StrategyId,
174        time_exchange: DateTime<Utc>,
175        side: Side,
176        price: Decimal,
177        quantity: Decimal,
178        order_filled_quantity: Option<Decimal>,
179        fees: AssetFees<AssetKey>,
180    ) -> Self {
181        Self {
182            id,
183            order_id,
184            instrument,
185            strategy,
186            time_exchange,
187            side,
188            price,
189            quantity,
190            order_filled_quantity,
191            fees,
192            origin: TradeOrigin::Order,
193            position_side: None,
194            client_order_id: None,
195        }
196    }
197
198    /// This trade with its [`origin`](Self::origin) set.
199    #[must_use]
200    pub fn with_origin(self, origin: TradeOrigin) -> Self {
201        Self { origin, ..self }
202    }
203
204    /// This trade with the [`position_side`](Self::position_side) the venue named.
205    #[must_use]
206    pub fn with_position_side(self, position_side: Side) -> Self {
207        Self {
208            position_side: Some(position_side),
209            ..self
210        }
211    }
212
213    /// This trade with the [`client_order_id`](Self::client_order_id) the venue reported.
214    #[must_use]
215    pub fn with_client_order_id(self, client_order_id: ClientOrderId) -> Self {
216        Self {
217            client_order_id: Some(client_order_id),
218            ..self
219        }
220    }
221
222    pub fn value_quote(&self) -> Decimal {
223        self.price * self.quantity.abs()
224    }
225
226    /// How this fill moves the position it applies to, when that is not by its own
227    /// [`side`](Self::side) and [`quantity`](Self::quantity): a spot fill whose fee the venue
228    /// charged in `instrument`'s base asset, or a `reduce_only` fill that would grow or flip the
229    /// position. `None` otherwise: the fill then moves the position by its own side and quantity.
230    ///
231    /// The `rustrade` engine applies every fill with this function. A component that mirrors
232    /// positions outside the engine, such as a ledger of the fills an execution client forwarded,
233    /// agrees with the engine by calling it too.
234    ///
235    /// `held` is the position the fill applies to, as it stands before the fill, signed: positive
236    /// for a long, negative for a short, zero for none. In an account holding a long and a short
237    /// at once, it is the one position the fill is routed to. `reduce_only` is whether the fill's
238    /// order was sent to reduce a position only, as
239    /// [`RequestOpen::reduce_only`](crate::order::request::RequestOpen::reduce_only) says.
240    ///
241    /// # The rule
242    ///
243    /// In this order:
244    ///
245    /// - **The fee is netted.** It leaves the account in base on either side, so the holding moves
246    ///   by the fill's signed quantity less the fee: a buy of `q` with a base fee `f` holds
247    ///   `q − f`, and a sell delivers `q + f`. A negative fee, a rebate, adds to it. Only on a
248    ///   spot instrument, spot margin included. A derivative's position is counted in contracts,
249    ///   and an inverse contract pays its fees in the settlement coin, so a fee there never
250    ///   changes the quantity. A fee in quote or a third asset, such as BNB, and a fee of zero
251    ///   are not netted.
252    /// - **A reduce-only move never grows or flips the position.** With `reduce_only`, a netted
253    ///   move larger than the opposite position `held` is cut to land it flat, and one that does
254    ///   not reduce it at all, because `held` is zero or on the move's side, moves nothing. What
255    ///   is cut off is [`HoldingMove::excess`]. A cut move is not rounded below: flat is on any
256    ///   step, and a position the fill left as it was is not the fill's to round. This applies to
257    ///   any instrument kind.
258    /// - **The position is rounded toward zero to the step.** Only when the fee was netted and
259    ///   the move not cut. When [`Instrument::quantity_step`] gives a step, the position the fill
260    ///   leaves, `held` plus the netted move, is rounded toward zero to a whole multiple of it, and
261    ///   the move is what lands the position there. A venue sells only whole steps, so a position
262    ///   off the step could never be closed to flat. It is the position that is rounded, not the
263    ///   move: a sell of 1 with a fee of 0.001 from a long of 2, in steps of 0.01, leaves 0.999, so
264    ///   the position is 0.99 and the move a sell of 1.01. What is rounded off is
265    ///   [`HoldingMove::dust`]. A `held` already off the step is rounded too, so the dust can
266    ///   include a remainder this fill did not create. Without a step, the dust is zero.
267    /// - **The move's side is its sign.** A fee larger than a buy's quantity sells the difference.
268    ///   A move of zero keeps the netted move's side, which is the fill's own unless the fee
269    ///   reversed it, with a quantity of zero.
270    ///
271    /// # What it leaves to the caller
272    ///
273    /// - **The price and the fee.** The netted quantity keeps the fill's price, so PnL is charged
274    ///   the fee's quote equivalent, its `fees_quote`: buying `q − f` at `p` and paying `f × p`
275    ///   costs `q × p`, what the fill paid. The engine also adds the value of a long's dust, at
276    ///   the fill's price, to the fill's fee, since the account holds that base but cannot sell
277    ///   it. A short's dust is base still owed on the loan, which the position books as bought
278    ///   back at the fill's price, so nothing is added.
279    /// - **Only the fee the venue reported** is netted. A fee the caller computes itself, as the
280    ///   engine's fee model does, is in quote, and reaches PnL alone.
281    /// - **The excess and its share of the fee.** No position takes the excess: the account
282    ///   holds it, or the venue spent it, as on a margin repayment sized to cover interest. The
283    ///   engine charges the position the share of the fill's fees that the move is of the netted
284    ///   move, and the rest to its tear sheet as fees no position carries. It reports the excess
285    ///   without charging its value to PnL.
286    /// - **A settlement fill larger than its position, in a hedged account.** Where an account
287    ///   can hold a long and a short at once (the engine's `OmsMode::Hedging`), the engine does not
288    ///   call this for a settlement fill, such as a liquidation, that matches none of its orders
289    ///   and is larger than the position it reduces: it cuts that fill to the position, unnetted,
290    ///   so the position lands flat, and reports the excess. A mirror that matches the engine does
291    ///   the same rather than calling this. Such a fill is of no order of the engine's, so it is
292    ///   never reduce-only. With one position per instrument (`OmsMode::Netting`), the engine
293    ///   never cuts a settlement fill, so this applies to every fill.
294    pub fn holding_move<ExchangeKey>(
295        &self,
296        instrument: &Instrument<ExchangeKey, AssetKey>,
297        held: Decimal,
298        reduce_only: bool,
299    ) -> Option<HoldingMove>
300    where
301        AssetKey: PartialEq,
302    {
303        let base_fee = matches!(instrument.kind, InstrumentKind::Spot)
304            && self.fees.asset == instrument.underlying.base
305            && !self.fees.fees.is_zero();
306        let netted = signed(self.side, self.quantity.abs())
307            - if base_fee {
308                self.fees.fees
309            } else {
310                Decimal::ZERO
311            };
312        let (netted_side, _) = unsigned(netted, self.side);
313
314        if reduce_only && let Some(moved) = reduced_by(held, netted) {
315            let (side, quantity) = unsigned(moved, netted_side);
316            return Some(HoldingMove {
317                side,
318                quantity,
319                dust: Decimal::ZERO,
320                excess: netted - moved,
321            });
322        }
323        if !base_fee {
324            return None;
325        }
326
327        let holds = held + netted;
328        let rounded = instrument
329            .quantity_step()
330            .map_or(holds, |step| round_toward_zero_to_step(holds, step));
331        let (side, quantity) = unsigned(rounded - held, netted_side);
332        Some(HoldingMove {
333            side,
334            quantity,
335            dust: holds - rounded,
336            excess: Decimal::ZERO,
337        })
338    }
339}
340
341/// How a fill moves the position it applies to, when that is not by its own side and quantity:
342/// see [`Trade::holding_move`].
343#[non_exhaustive]
344#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)]
345pub struct HoldingMove {
346    /// [`Side::Buy`] if the move adds to the position, [`Side::Sell`] if it takes from it.
347    pub side: Side,
348    /// The size of the move, never negative.
349    pub quantity: Decimal,
350    /// The part of the netted holding rounded off the position to land it on the quantity step,
351    /// signed as a position is, and less than one step in size. Positive is base the account holds
352    /// that a long does not count; negative is base still owed on a short's loan that the short
353    /// does not count. Zero without a step, or when the position lands on it.
354    ///
355    /// It stays in the account's balance. Each fill's is rounded off on its own, so the balance can
356    /// gather more than one step of it over many fills, which no position holds.
357    pub dust: Decimal,
358    /// The part of a reduce-only fill's netted move cut off so it does not grow or flip the
359    /// position, signed as a move is: positive for a buy. Zero unless the move was cut. No
360    /// position takes it.
361    pub excess: Decimal,
362}
363
364/// `quantity` with the sign of `side`: positive for a buy, or for a long position.
365fn signed(side: Side, quantity: Decimal) -> Decimal {
366    match side {
367        Side::Buy => quantity,
368        Side::Sell => -quantity,
369    }
370}
371
372/// The side and size of a signed move: a buy if positive, a sell if negative, and `at_zero`
373/// with a size of zero if it moves nothing.
374fn unsigned(moved: Decimal, at_zero: Side) -> (Side, Decimal) {
375    let side = match moved.cmp(&Decimal::ZERO) {
376        std::cmp::Ordering::Greater => Side::Buy,
377        std::cmp::Ordering::Less => Side::Sell,
378        std::cmp::Ordering::Equal => at_zero,
379    };
380    (side, moved.abs())
381}
382
383/// The part of a reduce-only signed move `moved` that the signed position `held` takes, when that is
384/// less than all of it: nothing unless the move reduces `held`, and no more than lands `held`
385/// flat. `None` when `held` takes all of it.
386fn reduced_by(held: Decimal, moved: Decimal) -> Option<Decimal> {
387    // Opposite signs reduce. A flat `held` has no sign to oppose, and a zero move takes nothing.
388    if held.cmp(&Decimal::ZERO) != moved.cmp(&Decimal::ZERO).reverse() {
389        return (!moved.is_zero()).then_some(Decimal::ZERO);
390    }
391    (moved.abs() > held.abs()).then_some(-held)
392}
393
394/// What one [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades) call
395/// read of the span it was asked for.
396///
397/// A venue reads a span in bounded calls, so one call may stop before the span's end. `resume`
398/// says whether this one did:
399/// - `None`: every fill in the span the venue still holds is in `trades`.
400/// - `Some(t)`: the call stopped at its bound, and fills from `t` on may be missing from
401///   `trades`. Read on from `start = t` with the same end. That read can return again fills this
402///   one returned at or after `t`, so match them by instrument and [`TradeId`].
403///
404/// `trades` can be empty while `resume` is `Some`: a venue that reads every instrument's fills
405/// and filters them afterwards can spend a whole call on other instruments'. A caller that takes
406/// `trades` without reading on from `resume` can miss fills.
407#[non_exhaustive]
408#[must_use = "`resume` says whether the span was read to its end"]
409#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
410pub struct TradesRead<AssetKey, InstrumentKey> {
411    /// The fills read, in the span and for the requested instruments.
412    pub trades: Vec<Trade<AssetKey, InstrumentKey>>,
413    /// Where to read on from, or `None` when the read reached the span's end.
414    pub resume: Option<DateTime<Utc>>,
415}
416
417impl<AssetKey, InstrumentKey> TradesRead<AssetKey, InstrumentKey> {
418    /// A read that reached the span's end.
419    pub fn complete(trades: Vec<Trade<AssetKey, InstrumentKey>>) -> Self {
420        Self {
421            trades,
422            resume: None,
423        }
424    }
425}
426
427impl<AssetKey, InstrumentKey> Display for Trade<AssetKey, InstrumentKey>
428where
429    AssetKey: Display,
430    InstrumentKey: Display,
431{
432    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
433        write!(
434            f,
435            "{{ instrument: {}, side: {}, price: {}, quantity: {}, time: {}",
436            self.instrument, self.side, self.price, self.quantity, self.time_exchange
437        )?;
438        // Shown only when it is not the default, so a forced fill stands out in a log line.
439        if self.origin != TradeOrigin::Order {
440            write!(f, ", origin: {}", self.origin)?;
441        }
442        write!(f, " }}")
443    }
444}
445
446/// Why a [`Trade`] happened, as far as its producer can tell.
447///
448/// # `Order` is the default, not a guarantee
449///
450/// [`Order`](Self::Order) means the producer reported no other origin. Most venues mark some
451/// origins and not others, so a fill can be `Order` and still be forced, or still be for an order
452/// this client did not place. What each producer reports, as of writing:
453///
454/// | Source | Reports |
455/// |---|---|
456/// | Interactive Brokers | [`Liquidation`](Self::Liquidation), [`External`](Self::External) (opt-in), `Order` |
457/// | Binance Spot / Margin | `Order` only |
458/// | Hyperliquid | `Order` only |
459/// | Alpaca | `Order` on fills; [`Expiry`](Self::Expiry), [`Exercise`](Self::Exercise), [`Assignment`](Self::Assignment) and [`External`](Self::External) from `AlpacaClient::fetch_option_events` |
460/// | [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) | `Order` only |
461/// | The `rustrade` engine's expiry settlement | [`Expiry`](Self::Expiry) |
462///
463/// - **Interactive Brokers** flags a fill of an IB-initiated liquidation. Another API client's
464///   fill is reported, as `External`, only when `IbkrConfig::other_clients_fills` opts in.
465/// - **Binance** marks no forced liquidation in its Spot or Margin order stream: its liquidation
466///   markers are documented for futures only. Every fill on the account arrives, whoever placed
467///   its order. `BinanceMargin::fetch_forced_liquidations` reads the margin orders Binance placed
468///   to liquidate the account, for a caller to match against its fills.
469/// - **Hyperliquid** fills carry a liquidation marker, but the SDK this crate uses drops it.
470/// - **Alpaca** marks no liquidation. Option assignment, exercise and expiry are account
471///   activities, which this crate does not read.
472/// - The **simulated venue** models no liquidation.
473///
474/// A forced variant takes precedence over [`External`](Self::External): a liquidation is
475/// reported as a liquidation whoever placed the order it closed.
476///
477/// # Serialised form and ordering
478///
479/// Serialised as the variant's name (`"Liquidation"`), as [`Side`] is, while `Display` writes it
480/// in lower case (`liquidation`). The names are a stored format: they are not renamed, and new
481/// variants are added after the existing ones. A trade with an origin a build does not know does
482/// not deserialise in that build.
483///
484/// `Ord` follows declaration order. It exists so [`Trade`] can derive `Ord`, and carries no
485/// meaning beyond that.
486#[non_exhaustive]
487#[derive(
488    Debug, Copy, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize,
489)]
490pub enum TradeOrigin {
491    /// A fill of an order, with no other origin reported. See the type's docs for why this is not
492    /// a guarantee that this client placed the order.
493    #[default]
494    Order,
495    /// The venue or broker closed the position because the account fell short of margin.
496    Liquidation,
497    /// The venue reduced the position against a counterparty it was liquidating
498    /// (auto-deleveraging).
499    Adl,
500    /// An option this account was short was assigned.
501    Assignment,
502    /// An option this account held was exercised, by the holder or automatically.
503    Exercise,
504    /// The instrument expired and the position was settled.
505    Expiry,
506    /// A fill of an order that another client, such as another API connection or the venue's own
507    /// trading screen, placed on the same account.
508    External,
509}
510
511impl TradeOrigin {
512    /// Whether the position changed without the account placing an order for it: a
513    /// [`Liquidation`](Self::Liquidation), an [`Adl`](Self::Adl) or an
514    /// [`Assignment`](Self::Assignment).
515    ///
516    /// An [`Exercise`](Self::Exercise) is the holder's right, and an [`Expiry`](Self::Expiry) is
517    /// the instrument's end, so neither counts.
518    pub fn is_forced(self) -> bool {
519        matches!(self, Self::Liquidation | Self::Adl | Self::Assignment)
520    }
521}
522
523impl Display for TradeOrigin {
524    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
525        f.write_str(match self {
526            Self::Order => "order",
527            Self::Liquidation => "liquidation",
528            Self::Adl => "adl",
529            Self::Assignment => "assignment",
530            Self::Exercise => "exercise",
531            Self::Expiry => "expiry",
532            Self::External => "external",
533        })
534    }
535}
536
537/// A venue's notice that a trade it reported earlier was busted or corrected.
538///
539/// Sent as [`AccountEventKind::TradeAmended`](crate::AccountEventKind::TradeAmended). The
540/// [`Trade`] it amends was delivered as reported and is not withdrawn: positions, PnL and fees
541/// built on it stay wrong until the consumer applies this. Find it by instrument and
542/// [`original`](Self::original), reverse it, and apply the [`kind`](Self::kind)'s replacement, if
543/// any. The library does not act on it beyond reporting it, and the engine only logs it.
544///
545/// A replacement can itself be amended later. That amendment names the replacement's id as its
546/// `original`, so follow amendments by id, one at a time.
547///
548/// # Known limitations
549///
550/// - An order's cumulative filled quantity is not lowered by a bust:
551///   [`Open::is_superseded_by`](crate::order::state::Open::is_superseded_by) refuses a lower
552///   cumulative, so the order stays on what it reported before.
553/// - Alpaca's fill recovery after a reconnect reads fills only, so an Alpaca amendment sent while
554///   the stream was disconnected is not reported. IBKR's recovery reads corrections too.
555///
556/// # Delivery
557///
558/// Apply an amendment idempotently: reverse a given `original` once, and apply a replacement
559/// once by its [`TradeId`]. Whether a venue can send one amendment twice, such as around a
560/// reconnect, is not known for every producer. The library delivers each IBKR correction once
561/// per account stream, but does not deduplicate Alpaca's amendments.
562///
563/// Known producers, as of writing:
564/// - Alpaca's `trade_updates` `trade_bust` and `trade_correct`;
565/// - IBKR's corrected executions, as [`Corrected`](TradeAmendmentKind::Corrected) only. IB
566///   documents no busts.
567#[non_exhaustive]
568#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
569pub struct TradeAmendment<AssetKey, InstrumentKey> {
570    /// The instrument of the trade amended.
571    pub instrument: InstrumentKey,
572    /// The order whose trade was amended.
573    pub order_id: OrderId,
574    /// When the venue amended the trade, or when it was received where the venue does not say.
575    pub time_exchange: DateTime<Utc>,
576    /// The [`TradeId`] of the trade amended, as the venue named it. `None` when the venue's notice
577    /// did not name it: match it to a trade of [`order_id`](Self::order_id) yourself, or
578    /// reconcile with
579    /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades).
580    pub original: Option<TradeId>,
581    /// What the venue did to the trade.
582    pub kind: TradeAmendmentKind<AssetKey, InstrumentKey>,
583}
584
585/// What a [`TradeAmendment`] did to the trade.
586#[non_exhaustive]
587#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)]
588pub enum TradeAmendmentKind<AssetKey, InstrumentKey> {
589    /// The trade was cancelled: it did not happen.
590    Busted {
591        /// The quantity busted, as a magnitude, when the venue said. A venue that reports it as
592        /// a negative reversal is read as its absolute value.
593        quantity: Option<Decimal>,
594    },
595    /// The trade was replaced, in full.
596    Corrected {
597        /// The trade as corrected: its price and quantity are the corrected values, not
598        /// differences from the original's. It has its own [`TradeId`], which a later amendment
599        /// of it names.
600        replacement: Trade<AssetKey, InstrumentKey>,
601    },
602    /// The trade was corrected, but the venue's notice lacked what a replacement [`Trade`] needs.
603    /// What it did carry is here. Reconcile the trade with
604    /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades).
605    CorrectedUnresolved {
606        /// The id of the replacement, which a later amendment of it names.
607        id: Option<TradeId>,
608        /// The corrected price, if the notice had one.
609        price: Option<Decimal>,
610        /// The corrected quantity, if the notice had one.
611        quantity: Option<Decimal>,
612    },
613}
614
615#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
616pub struct AssetFees<AssetKey> {
617    pub asset: AssetKey,
618    pub fees: Decimal,
619    /// Fee value in quote currency when computable.
620    /// - `Some(fees)` if fee asset == quote asset
621    /// - `Some(fees * price)` if fee asset == base asset (computed by indexer)
622    /// - `None` if fee asset is third-party (e.g., BNB), which needs a price the indexer does not
623    ///   have. The `rustrade` engine fills it in when it prices that asset, before applying the
624    ///   fill (see its `EngineState::update_from_account`). Also `None` when the indexer zeroed a
625    ///   fee whose asset was not in its map (see
626    ///   [`AccountEventIndexer::trade`](crate::indexer::AccountEventIndexer::trade)).
627    ///
628    /// The engine's position PnL counts only this quote equivalent. A fee with `None` is left
629    /// out of PnL, and its raw [`fees`](Self::fees) stays recorded on the position. On a
630    /// position's running fee totals, this is the sum of the quote equivalents its fees had, and
631    /// `None` only while none had one.
632    #[serde(default, skip_serializing_if = "Option::is_none")]
633    pub fees_quote: Option<Decimal>,
634}
635
636impl<AssetKey> AssetFees<AssetKey> {
637    pub fn new(asset: AssetKey, fees: Decimal, fees_quote: Option<Decimal>) -> Self {
638        Self {
639            asset,
640            fees,
641            fees_quote,
642        }
643    }
644}
645
646impl AssetFees<QuoteAsset> {
647    /// Construct fees already denominated in quote asset.
648    /// Sets `fees_quote = Some(fees)` since no conversion needed.
649    pub fn quote_fees(fees: Decimal) -> Self {
650        Self {
651            asset: QuoteAsset,
652            fees,
653            fees_quote: Some(fees),
654        }
655    }
656}
657
658impl Default for AssetFees<QuoteAsset> {
659    fn default() -> Self {
660        Self {
661            asset: QuoteAsset,
662            fees: Decimal::ZERO,
663            fees_quote: Some(Decimal::ZERO),
664        }
665    }
666}
667
668impl<AssetKey> Default for AssetFees<Option<AssetKey>> {
669    fn default() -> Self {
670        Self {
671            asset: None,
672            fees: Decimal::ZERO,
673            fees_quote: None,
674        }
675    }
676}
677
678#[cfg(test)]
679#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
680mod tests {
681    use super::*;
682    use rust_decimal_macros::dec;
683    use rustrade_instrument::{
684        asset::{Asset, name::AssetNameExchange},
685        exchange::ExchangeId,
686        instrument::{
687            kind::perpetual::PerpetualContract,
688            name::InstrumentNameExchange,
689            spec::{
690                InstrumentSpec, InstrumentSpecNotional, InstrumentSpecPrice,
691                InstrumentSpecQuantity, OrderQuantityUnits,
692            },
693        },
694        test_utils,
695    };
696
697    fn trade() -> Trade<AssetNameExchange, InstrumentNameExchange> {
698        Trade::new(
699            TradeId::new("t-1"),
700            OrderId::new("o-1"),
701            InstrumentNameExchange::new("btc_usdt"),
702            StrategyId::unknown(),
703            DateTime::<Utc>::MIN_UTC,
704            Side::Sell,
705            Decimal::ONE,
706            Decimal::TWO,
707            None,
708            AssetFees::new(AssetNameExchange::from("usdt"), Decimal::ZERO, None),
709        )
710    }
711
712    #[test]
713    fn a_trade_is_of_an_order_unless_given_another_origin() {
714        assert_eq!(trade().origin, TradeOrigin::Order);
715        assert_eq!(
716            trade().with_origin(TradeOrigin::Adl).origin,
717            TradeOrigin::Adl
718        );
719    }
720
721    /// A trade serialised before `origin` existed still loads, as an order's.
722    #[test]
723    fn a_trade_without_an_origin_deserialises_as_an_orders() {
724        let mut json = serde_json::to_value(trade().with_origin(TradeOrigin::External)).unwrap();
725        assert_eq!(json["origin"], "External");
726        json.as_object_mut().unwrap().remove("origin");
727
728        let read: Trade<AssetNameExchange, InstrumentNameExchange> =
729            serde_json::from_value(json).unwrap();
730        assert_eq!(read, trade());
731    }
732
733    #[test]
734    fn display_names_the_origin_only_when_it_is_not_an_orders() {
735        assert!(!trade().to_string().contains("origin"));
736        assert!(
737            trade()
738                .with_origin(TradeOrigin::Liquidation)
739                .to_string()
740                .ends_with(", origin: liquidation }")
741        );
742    }
743
744    // --- holding_move ------------------------------------------------------------------------
745
746    /// A spot BTC/USDT instrument, with a quantity step in BTC when `step` is given.
747    fn btc_usdt(step: Option<Decimal>) -> Instrument<ExchangeId, Asset> {
748        let mut instrument = test_utils::instrument(ExchangeId::BinanceSpot, "btc", "usdt");
749        instrument.spec = step.map(|step| {
750            InstrumentSpec::new(
751                InstrumentSpecPrice::new(dec!(0.01), dec!(0.01)),
752                InstrumentSpecQuantity::new(
753                    OrderQuantityUnits::Asset(test_utils::asset("btc")),
754                    step,
755                    step,
756                ),
757                InstrumentSpecNotional::new(Decimal::ZERO),
758            )
759        });
760        instrument
761    }
762
763    /// A fill of `quantity` on `side`, charged `fee` in `fee_asset`.
764    fn fill(side: Side, quantity: Decimal, fee_asset: &str, fee: Decimal) -> Trade<Asset, ()> {
765        Trade::new(
766            TradeId::new("t-1"),
767            OrderId::new("o-1"),
768            (),
769            StrategyId::unknown(),
770            DateTime::<Utc>::MIN_UTC,
771            side,
772            dec!(100),
773            quantity,
774            None,
775            AssetFees::new(test_utils::asset(fee_asset), fee, None),
776        )
777    }
778
779    fn moved(side: Side, quantity: Decimal, dust: Decimal) -> Option<HoldingMove> {
780        Some(HoldingMove {
781            side,
782            quantity,
783            dust,
784            excess: Decimal::ZERO,
785        })
786    }
787
788    fn cut(side: Side, quantity: Decimal, excess: Decimal) -> Option<HoldingMove> {
789        Some(HoldingMove {
790            side,
791            quantity,
792            dust: Decimal::ZERO,
793            excess,
794        })
795    }
796
797    #[test]
798    fn holding_move_is_none_unless_a_spot_fill_pays_a_base_fee() {
799        let mut perpetual = btc_usdt(None);
800        perpetual.kind = InstrumentKind::Perpetual(PerpetualContract {
801            contract_size: Decimal::ONE,
802            settlement_asset: test_utils::asset("btc"),
803        });
804        let cases = [
805            (
806                "quote fee",
807                btc_usdt(None),
808                fill(Side::Buy, dec!(1), "usdt", dec!(0.1)),
809            ),
810            (
811                "third-asset fee",
812                btc_usdt(None),
813                fill(Side::Buy, dec!(1), "bnb", dec!(0.001)),
814            ),
815            (
816                "zero base fee",
817                btc_usdt(None),
818                fill(Side::Buy, dec!(1), "btc", Decimal::ZERO),
819            ),
820            (
821                "derivative",
822                perpetual,
823                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
824            ),
825        ];
826
827        for (case, instrument, fill) in cases {
828            assert_eq!(
829                fill.holding_move(&instrument, Decimal::ZERO, false),
830                None,
831                "{case}"
832            );
833        }
834    }
835
836    #[test]
837    fn holding_move_nets_a_base_fee_on_either_side() {
838        let spot = btc_usdt(None);
839        let cases = [
840            (
841                "buy holds less",
842                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
843                moved(Side::Buy, dec!(0.999), Decimal::ZERO),
844            ),
845            (
846                "sell delivers more",
847                fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
848                moved(Side::Sell, dec!(1.001), Decimal::ZERO),
849            ),
850            (
851                "rebate adds",
852                fill(Side::Buy, dec!(1), "btc", dec!(-0.001)),
853                moved(Side::Buy, dec!(1.001), Decimal::ZERO),
854            ),
855            (
856                "fee above a buy sells",
857                fill(Side::Buy, dec!(0.001), "btc", dec!(0.003)),
858                moved(Side::Sell, dec!(0.002), Decimal::ZERO),
859            ),
860            (
861                "fee equal to a buy moves nothing",
862                fill(Side::Buy, dec!(0.001), "btc", dec!(0.001)),
863                moved(Side::Buy, Decimal::ZERO, Decimal::ZERO),
864            ),
865        ];
866
867        for (case, fill, expected) in cases {
868            assert_eq!(fill.holding_move(&spot, dec!(2), false), expected, "{case}");
869        }
870    }
871
872    /// The position `held` plus the netted move is what is rounded, toward zero, and the move is
873    /// what lands it there.
874    #[test]
875    fn holding_move_rounds_the_position_toward_zero_to_the_step() {
876        let spot = btc_usdt(Some(dec!(0.01)));
877        let cases = [
878            (
879                "buy from flat",
880                Decimal::ZERO,
881                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
882                moved(Side::Buy, dec!(0.99), dec!(0.009)),
883            ),
884            // Rounding the move of 1.001 instead would leave a long of 1, more than the 0.999 held.
885            (
886                "sell from a long",
887                dec!(2),
888                fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
889                moved(Side::Sell, dec!(1.01), dec!(0.009)),
890            ),
891            // The fee leaves a short of 0.001, rounded away: base still owed, which no short counts.
892            (
893                "buy back a short",
894                dec!(-1),
895                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
896                moved(Side::Buy, dec!(1), dec!(-0.001)),
897            ),
898            (
899                "sell opening a short",
900                Decimal::ZERO,
901                fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
902                moved(Side::Sell, dec!(1), dec!(-0.001)),
903            ),
904            (
905                "held off the step",
906                dec!(0.005),
907                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
908                moved(Side::Buy, dec!(0.995), dec!(0.004)),
909            ),
910            (
911                "lands on the step",
912                dec!(0.001),
913                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
914                moved(Side::Buy, dec!(0.999), Decimal::ZERO),
915            ),
916        ];
917
918        for (case, held, fill, expected) in cases {
919            assert_eq!(fill.holding_move(&spot, held, false), expected, "{case}");
920        }
921    }
922
923    /// A rounded move of zero keeps the netted move's side: the fill's own, unless the fee
924    /// reversed it.
925    #[test]
926    fn holding_move_rounded_to_nothing_keeps_the_netted_side() {
927        let spot = btc_usdt(Some(dec!(1)));
928        let cases = [
929            (
930                "buy",
931                dec!(1),
932                fill(Side::Buy, dec!(0.5), "btc", dec!(0.001)),
933                moved(Side::Buy, Decimal::ZERO, dec!(0.499)),
934            ),
935            (
936                "fee reverses the buy",
937                dec!(-1),
938                fill(Side::Buy, dec!(0.001), "btc", dec!(0.003)),
939                moved(Side::Sell, Decimal::ZERO, dec!(-0.002)),
940            ),
941        ];
942
943        for (case, held, fill, expected) in cases {
944            assert_eq!(fill.holding_move(&spot, held, false), expected, "{case}");
945        }
946    }
947
948    /// A reduce-only move larger than the opposite position is cut to land it flat; one that
949    /// reduces nothing moves nothing. Either way the cut is the excess, and nothing is rounded.
950    #[test]
951    fn holding_move_cuts_a_reduce_only_move_at_the_position() {
952        let spot = btc_usdt(Some(dec!(0.01)));
953        let cases = [
954            // A repayment buy sized above the short: netted to 10.49, cut at 10.
955            (
956                "buy past a short",
957                dec!(-10),
958                fill(Side::Buy, dec!(10.5), "btc", dec!(0.01)),
959                cut(Side::Buy, dec!(10), dec!(0.49)),
960            ),
961            (
962                "sell past a long",
963                dec!(1),
964                fill(Side::Sell, dec!(1.5), "usdt", dec!(0.1)),
965                cut(Side::Sell, dec!(1), dec!(-0.5)),
966            ),
967            (
968                "buy from flat",
969                Decimal::ZERO,
970                fill(Side::Buy, dec!(1), "usdt", dec!(0.1)),
971                cut(Side::Buy, Decimal::ZERO, dec!(1)),
972            ),
973            (
974                "buy onto a long",
975                dec!(1),
976                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
977                cut(Side::Buy, Decimal::ZERO, dec!(0.999)),
978            ),
979            // The fee reverses the buy into a sell, which a short does not reduce.
980            (
981                "fee reverses a buy onto a short",
982                dec!(-1),
983                fill(Side::Buy, dec!(0.001), "btc", dec!(0.003)),
984                cut(Side::Sell, Decimal::ZERO, dec!(-0.002)),
985            ),
986        ];
987
988        for (case, held, fill, expected) in cases {
989            assert_eq!(fill.holding_move(&spot, held, true), expected, "{case}");
990        }
991    }
992
993    /// A reduce-only move the position takes whole is moved as any other fill: rounded when its
994    /// base fee is netted, and `None` when it is not.
995    #[test]
996    fn holding_move_leaves_a_reduce_only_move_the_position_takes() {
997        let spot = btc_usdt(Some(dec!(0.01)));
998        let cases = [
999            (
1000                "partial, base fee",
1001                dec!(-2),
1002                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
1003                moved(Side::Buy, dec!(1), dec!(-0.001)),
1004            ),
1005            (
1006                "to flat exactly, base fee",
1007                dec!(-0.999),
1008                fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
1009                moved(Side::Buy, dec!(0.999), Decimal::ZERO),
1010            ),
1011            (
1012                "partial, rounded",
1013                dec!(2),
1014                fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
1015                moved(Side::Sell, dec!(1.01), dec!(0.009)),
1016            ),
1017            (
1018                "to flat exactly, quote fee",
1019                dec!(1),
1020                fill(Side::Sell, dec!(1), "usdt", dec!(0.1)),
1021                None,
1022            ),
1023            (
1024                "fee equal to a buy moves nothing",
1025                dec!(-1),
1026                fill(Side::Buy, dec!(0.001), "btc", dec!(0.001)),
1027                moved(Side::Buy, Decimal::ZERO, Decimal::ZERO),
1028            ),
1029            // Nothing to cut, so a short already off the step is still rounded.
1030            (
1031                "fee equal to a buy, held off the step",
1032                dec!(-1.005),
1033                fill(Side::Buy, dec!(0.001), "btc", dec!(0.001)),
1034                moved(Side::Buy, dec!(0.005), dec!(-0.005)),
1035            ),
1036        ];
1037
1038        for (case, held, fill, expected) in cases {
1039            assert_eq!(fill.holding_move(&spot, held, true), expected, "{case}");
1040        }
1041    }
1042
1043    /// The cut applies to any instrument kind, where no fee is netted.
1044    #[test]
1045    fn holding_move_cuts_a_reduce_only_derivative_fill() {
1046        let mut perpetual = btc_usdt(None);
1047        perpetual.kind = InstrumentKind::Perpetual(PerpetualContract {
1048            contract_size: Decimal::ONE,
1049            settlement_asset: test_utils::asset("btc"),
1050        });
1051        let fill = fill(Side::Buy, dec!(3), "btc", dec!(0.001));
1052
1053        assert_eq!(
1054            fill.holding_move(&perpetual, dec!(-2), true),
1055            cut(Side::Buy, dec!(2), dec!(1))
1056        );
1057        assert_eq!(fill.holding_move(&perpetual, dec!(-3), true), None);
1058        assert_eq!(fill.holding_move(&perpetual, dec!(-2), false), None);
1059    }
1060
1061    #[test]
1062    fn only_liquidation_adl_and_assignment_are_forced() {
1063        let forced: Vec<_> = [
1064            TradeOrigin::Order,
1065            TradeOrigin::Liquidation,
1066            TradeOrigin::Adl,
1067            TradeOrigin::Assignment,
1068            TradeOrigin::Exercise,
1069            TradeOrigin::Expiry,
1070            TradeOrigin::External,
1071        ]
1072        .into_iter()
1073        .filter(|origin| origin.is_forced())
1074        .collect();
1075        assert_eq!(
1076            forced,
1077            [
1078                TradeOrigin::Liquidation,
1079                TradeOrigin::Adl,
1080                TradeOrigin::Assignment
1081            ]
1082        );
1083    }
1084}