Skip to main content

rustrade_execution/
trade.rs

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