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///
408/// A venue also keeps fills for a limited time or count. `held_since` says when that limit cuts
409/// into the span, so a caller can tell fills the venue no longer has from fills that never
410/// happened.
411#[non_exhaustive]
412#[must_use = "`resume` says whether the span was read to its end"]
413#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)]
414pub struct TradesRead<AssetKey, InstrumentKey> {
415 /// The fills read, in the span and for the requested instruments.
416 pub trades: Vec<Trade<AssetKey, InstrumentKey>>,
417 /// Where to read on from, or `None` when the read reached the span's end.
418 pub resume: Option<DateTime<Utc>>,
419 /// The earliest moment the read covers, when the span starts before it; fills before it may
420 /// exist but be missing from `trades`. It is after the span's end when none of the span can
421 /// be read. `None` when the venue's limit is not known to cut into the span, which is no
422 /// claim that the venue holds fills without limit.
423 ///
424 /// A caller that needs every fill in the span treats the part before `held_since` as a gap:
425 /// `resume: None` covers what the venue holds, not what it held. The limit cuts into the span
426 /// from its start, so only the first call of a span can report it; a caller reading on from
427 /// `resume` has nothing to learn from a later call's.
428 pub held_since: Option<DateTime<Utc>>,
429}
430
431impl<AssetKey, InstrumentKey> TradesRead<AssetKey, InstrumentKey> {
432 /// A read that stopped at `resume` (`None`: it reached the span's end), with no venue limit
433 /// cutting into the span.
434 pub fn new(trades: Vec<Trade<AssetKey, InstrumentKey>>, resume: Option<DateTime<Utc>>) -> Self {
435 Self {
436 trades,
437 resume,
438 held_since: None,
439 }
440 }
441
442 /// A read that reached the span's end.
443 pub fn complete(trades: Vec<Trade<AssetKey, InstrumentKey>>) -> Self {
444 Self::new(trades, None)
445 }
446
447 /// Sets [`held_since`](Self::held_since).
448 pub fn with_held_since(mut self, held_since: Option<DateTime<Utc>>) -> Self {
449 self.held_since = held_since;
450 self
451 }
452}
453
454impl<AssetKey, InstrumentKey> Display for Trade<AssetKey, InstrumentKey>
455where
456 AssetKey: Display,
457 InstrumentKey: Display,
458{
459 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
460 write!(
461 f,
462 "{{ instrument: {}, side: {}, price: {}, quantity: {}, time: {}",
463 self.instrument, self.side, self.price, self.quantity, self.time_exchange
464 )?;
465 // Shown only when it is not the default, so a forced fill stands out in a log line.
466 if self.origin != TradeOrigin::Order {
467 write!(f, ", origin: {}", self.origin)?;
468 }
469 write!(f, " }}")
470 }
471}
472
473/// Why a [`Trade`] happened, as far as its producer can tell.
474///
475/// # `Order` is the default, not a guarantee
476///
477/// [`Order`](Self::Order) means the producer reported no other origin. Most venues mark some
478/// origins and not others, so a fill can be `Order` and still be forced, or still be for an order
479/// this client did not place. What each producer reports, as of writing:
480///
481/// | Source | Reports |
482/// |---|---|
483/// | Interactive Brokers | [`Liquidation`](Self::Liquidation), [`External`](Self::External) (opt-in), `Order` |
484/// | Binance Spot / Margin | `Order` only |
485/// | Hyperliquid | `Order` only |
486/// | Alpaca | `Order` on fills; [`Expiry`](Self::Expiry), [`Exercise`](Self::Exercise), [`Assignment`](Self::Assignment) and [`External`](Self::External) from `AlpacaClient::fetch_option_events` |
487/// | [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) | `Order` only |
488/// | The `rustrade` engine's expiry settlement | [`Expiry`](Self::Expiry) |
489///
490/// - **Interactive Brokers** flags a fill of an IB-initiated liquidation. Another API client's
491/// fill is reported, as `External`, only when `IbkrConfig::other_clients_fills` opts in.
492/// - **Binance** marks no forced liquidation in its Spot or Margin order stream: its liquidation
493/// markers are documented for futures only. Every fill on the account arrives, whoever placed
494/// its order. `BinanceMargin::fetch_forced_liquidations` reads the margin orders Binance placed
495/// to liquidate the account, for a caller to match against its fills.
496/// - **Hyperliquid** fills carry a liquidation marker, but the SDK this crate uses drops it.
497/// - **Alpaca** marks no liquidation. Option assignment, exercise and expiry are account
498/// activities, which this crate does not read.
499/// - The **simulated venue** models no liquidation.
500///
501/// A forced variant takes precedence over [`External`](Self::External): a liquidation is
502/// reported as a liquidation whoever placed the order it closed.
503///
504/// # Serialised form and ordering
505///
506/// Serialised as the variant's name (`"Liquidation"`), as [`Side`] is, while `Display` writes it
507/// in lower case (`liquidation`). The names are a stored format: they are not renamed, and new
508/// variants are added after the existing ones. A trade with an origin a build does not know does
509/// not deserialise in that build.
510///
511/// `Ord` follows declaration order. It exists so [`Trade`] can derive `Ord`, and carries no
512/// meaning beyond that.
513#[non_exhaustive]
514#[derive(
515 Debug, Copy, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize,
516)]
517pub enum TradeOrigin {
518 /// A fill of an order, with no other origin reported. See the type's docs for why this is not
519 /// a guarantee that this client placed the order.
520 #[default]
521 Order,
522 /// The venue or broker closed the position because the account fell short of margin.
523 Liquidation,
524 /// The venue reduced the position against a counterparty it was liquidating
525 /// (auto-deleveraging).
526 Adl,
527 /// An option this account was short was assigned.
528 Assignment,
529 /// An option this account held was exercised, by the holder or automatically.
530 Exercise,
531 /// The instrument expired and the position was settled.
532 Expiry,
533 /// A fill of an order that another client, such as another API connection or the venue's own
534 /// trading screen, placed on the same account.
535 External,
536}
537
538impl TradeOrigin {
539 /// Whether the position changed without the account placing an order for it: a
540 /// [`Liquidation`](Self::Liquidation), an [`Adl`](Self::Adl) or an
541 /// [`Assignment`](Self::Assignment).
542 ///
543 /// An [`Exercise`](Self::Exercise) is the holder's right, and an [`Expiry`](Self::Expiry) is
544 /// the instrument's end, so neither counts.
545 pub fn is_forced(self) -> bool {
546 matches!(self, Self::Liquidation | Self::Adl | Self::Assignment)
547 }
548}
549
550impl Display for TradeOrigin {
551 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
552 f.write_str(match self {
553 Self::Order => "order",
554 Self::Liquidation => "liquidation",
555 Self::Adl => "adl",
556 Self::Assignment => "assignment",
557 Self::Exercise => "exercise",
558 Self::Expiry => "expiry",
559 Self::External => "external",
560 })
561 }
562}
563
564/// A venue's notice that a trade it reported earlier was busted or corrected.
565///
566/// Sent as [`AccountEventKind::TradeAmended`](crate::AccountEventKind::TradeAmended). The
567/// [`Trade`] it amends was delivered as reported and is not withdrawn: positions, PnL and fees
568/// built on it stay wrong until the consumer applies this. Find it by instrument and
569/// [`original`](Self::original), reverse it, and apply the [`kind`](Self::kind)'s replacement, if
570/// any. The library does not act on it beyond reporting it, and the engine only logs it.
571///
572/// A replacement can itself be amended later. That amendment names the replacement's id as its
573/// `original`, so follow amendments by id, one at a time.
574///
575/// # Known limitations
576///
577/// - An order's cumulative filled quantity is not lowered by a bust:
578/// [`Open::is_superseded_by`](crate::order::state::Open::is_superseded_by) refuses a lower
579/// cumulative, so the order stays on what it reported before.
580/// - Alpaca's fill recovery after a reconnect reads fills only, so an Alpaca amendment sent while
581/// the stream was disconnected is not reported. IBKR's recovery reads corrections too.
582///
583/// # Delivery
584///
585/// Apply an amendment idempotently: reverse a given `original` once, and apply a replacement
586/// once by its [`TradeId`]. Whether a venue can send one amendment twice, such as around a
587/// reconnect, is not known for every producer. The library delivers each IBKR correction once
588/// per account stream, but does not deduplicate Alpaca's amendments.
589///
590/// Known producers, as of writing:
591/// - Alpaca's `trade_updates` `trade_bust` and `trade_correct`;
592/// - IBKR's corrected executions, as [`Corrected`](TradeAmendmentKind::Corrected) only. IB
593/// documents no busts.
594#[non_exhaustive]
595#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
596pub struct TradeAmendment<AssetKey, InstrumentKey> {
597 /// The instrument of the trade amended.
598 pub instrument: InstrumentKey,
599 /// The order whose trade was amended.
600 pub order_id: OrderId,
601 /// When the venue amended the trade, or when it was received where the venue does not say.
602 pub time_exchange: DateTime<Utc>,
603 /// The [`TradeId`] of the trade amended, as the venue named it. `None` when the venue's notice
604 /// did not name it: match it to a trade of [`order_id`](Self::order_id) yourself, or
605 /// reconcile with
606 /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades).
607 pub original: Option<TradeId>,
608 /// What the venue did to the trade.
609 pub kind: TradeAmendmentKind<AssetKey, InstrumentKey>,
610}
611
612/// What a [`TradeAmendment`] did to the trade.
613#[non_exhaustive]
614#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)]
615pub enum TradeAmendmentKind<AssetKey, InstrumentKey> {
616 /// The trade was cancelled: it did not happen.
617 Busted {
618 /// The quantity busted, as a magnitude, when the venue said. A venue that reports it as
619 /// a negative reversal is read as its absolute value.
620 quantity: Option<Decimal>,
621 },
622 /// The trade was replaced, in full.
623 Corrected {
624 /// The trade as corrected: its price and quantity are the corrected values, not
625 /// differences from the original's. It has its own [`TradeId`], which a later amendment
626 /// of it names.
627 replacement: Trade<AssetKey, InstrumentKey>,
628 },
629 /// The trade was corrected, but the venue's notice lacked what a replacement [`Trade`] needs.
630 /// What it did carry is here. Reconcile the trade with
631 /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades).
632 CorrectedUnresolved {
633 /// The id of the replacement, which a later amendment of it names.
634 id: Option<TradeId>,
635 /// The corrected price, if the notice had one.
636 price: Option<Decimal>,
637 /// The corrected quantity, if the notice had one.
638 quantity: Option<Decimal>,
639 },
640}
641
642#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
643pub struct AssetFees<AssetKey> {
644 pub asset: AssetKey,
645 pub fees: Decimal,
646 /// Fee value in quote currency when computable.
647 /// - `Some(fees)` if fee asset == quote asset
648 /// - `Some(fees * price)` if fee asset == base asset (computed by indexer)
649 /// - `None` if fee asset is third-party (e.g., BNB), which needs a price the indexer does not
650 /// have. The `rustrade` engine fills it in when it prices that asset, before applying the
651 /// fill (see its `EngineState::update_from_account`). Also `None` when the indexer zeroed a
652 /// fee whose asset was not in its map (see
653 /// [`AccountEventIndexer::trade`](crate::indexer::AccountEventIndexer::trade)).
654 ///
655 /// The engine's position PnL counts only this quote equivalent. A fee with `None` is left
656 /// out of PnL, and its raw [`fees`](Self::fees) stays recorded on the position. On a
657 /// position's running fee totals, this is the sum of the quote equivalents its fees had, and
658 /// `None` only while none had one.
659 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub fees_quote: Option<Decimal>,
661}
662
663impl<AssetKey> AssetFees<AssetKey> {
664 pub fn new(asset: AssetKey, fees: Decimal, fees_quote: Option<Decimal>) -> Self {
665 Self {
666 asset,
667 fees,
668 fees_quote,
669 }
670 }
671}
672
673impl AssetFees<QuoteAsset> {
674 /// Construct fees already denominated in quote asset.
675 /// Sets `fees_quote = Some(fees)` since no conversion needed.
676 pub fn quote_fees(fees: Decimal) -> Self {
677 Self {
678 asset: QuoteAsset,
679 fees,
680 fees_quote: Some(fees),
681 }
682 }
683}
684
685impl Default for AssetFees<QuoteAsset> {
686 fn default() -> Self {
687 Self {
688 asset: QuoteAsset,
689 fees: Decimal::ZERO,
690 fees_quote: Some(Decimal::ZERO),
691 }
692 }
693}
694
695impl<AssetKey> Default for AssetFees<Option<AssetKey>> {
696 fn default() -> Self {
697 Self {
698 asset: None,
699 fees: Decimal::ZERO,
700 fees_quote: None,
701 }
702 }
703}
704
705#[cfg(test)]
706#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
707mod tests {
708 use super::*;
709 use rust_decimal_macros::dec;
710 use rustrade_instrument::{
711 asset::{Asset, name::AssetNameExchange},
712 exchange::ExchangeId,
713 instrument::{
714 kind::perpetual::PerpetualContract,
715 name::InstrumentNameExchange,
716 spec::{
717 InstrumentSpec, InstrumentSpecNotional, InstrumentSpecPrice,
718 InstrumentSpecQuantity, OrderQuantityUnits,
719 },
720 },
721 test_utils,
722 };
723
724 fn trade() -> Trade<AssetNameExchange, InstrumentNameExchange> {
725 Trade::new(
726 TradeId::new("t-1"),
727 OrderId::new("o-1"),
728 InstrumentNameExchange::new("btc_usdt"),
729 StrategyId::unknown(),
730 DateTime::<Utc>::MIN_UTC,
731 Side::Sell,
732 Decimal::ONE,
733 Decimal::TWO,
734 None,
735 AssetFees::new(AssetNameExchange::from("usdt"), Decimal::ZERO, None),
736 )
737 }
738
739 #[test]
740 fn a_trade_is_of_an_order_unless_given_another_origin() {
741 assert_eq!(trade().origin, TradeOrigin::Order);
742 assert_eq!(
743 trade().with_origin(TradeOrigin::Adl).origin,
744 TradeOrigin::Adl
745 );
746 }
747
748 /// A trade serialised before `origin` existed still loads, as an order's.
749 #[test]
750 fn a_trade_without_an_origin_deserialises_as_an_orders() {
751 let mut json = serde_json::to_value(trade().with_origin(TradeOrigin::External)).unwrap();
752 assert_eq!(json["origin"], "External");
753 json.as_object_mut().unwrap().remove("origin");
754
755 let read: Trade<AssetNameExchange, InstrumentNameExchange> =
756 serde_json::from_value(json).unwrap();
757 assert_eq!(read, trade());
758 }
759
760 #[test]
761 fn display_names_the_origin_only_when_it_is_not_an_orders() {
762 assert!(!trade().to_string().contains("origin"));
763 assert!(
764 trade()
765 .with_origin(TradeOrigin::Liquidation)
766 .to_string()
767 .ends_with(", origin: liquidation }")
768 );
769 }
770
771 // --- holding_move ------------------------------------------------------------------------
772
773 /// A spot BTC/USDT instrument, with a quantity step in BTC when `step` is given.
774 fn btc_usdt(step: Option<Decimal>) -> Instrument<ExchangeId, Asset> {
775 let mut instrument = test_utils::instrument(ExchangeId::BinanceSpot, "btc", "usdt");
776 instrument.spec = step.map(|step| {
777 InstrumentSpec::new(
778 InstrumentSpecPrice::new(dec!(0.01), dec!(0.01)),
779 InstrumentSpecQuantity::new(
780 OrderQuantityUnits::Asset(test_utils::asset("btc")),
781 step,
782 step,
783 ),
784 InstrumentSpecNotional::new(Decimal::ZERO),
785 )
786 });
787 instrument
788 }
789
790 /// A fill of `quantity` on `side`, charged `fee` in `fee_asset`.
791 fn fill(side: Side, quantity: Decimal, fee_asset: &str, fee: Decimal) -> Trade<Asset, ()> {
792 Trade::new(
793 TradeId::new("t-1"),
794 OrderId::new("o-1"),
795 (),
796 StrategyId::unknown(),
797 DateTime::<Utc>::MIN_UTC,
798 side,
799 dec!(100),
800 quantity,
801 None,
802 AssetFees::new(test_utils::asset(fee_asset), fee, None),
803 )
804 }
805
806 fn moved(side: Side, quantity: Decimal, dust: Decimal) -> Option<HoldingMove> {
807 Some(HoldingMove {
808 side,
809 quantity,
810 dust,
811 excess: Decimal::ZERO,
812 })
813 }
814
815 fn cut(side: Side, quantity: Decimal, excess: Decimal) -> Option<HoldingMove> {
816 Some(HoldingMove {
817 side,
818 quantity,
819 dust: Decimal::ZERO,
820 excess,
821 })
822 }
823
824 #[test]
825 fn holding_move_is_none_unless_a_spot_fill_pays_a_base_fee() {
826 let mut perpetual = btc_usdt(None);
827 perpetual.kind = InstrumentKind::Perpetual(PerpetualContract {
828 contract_size: Decimal::ONE,
829 settlement_asset: test_utils::asset("btc"),
830 });
831 let cases = [
832 (
833 "quote fee",
834 btc_usdt(None),
835 fill(Side::Buy, dec!(1), "usdt", dec!(0.1)),
836 ),
837 (
838 "third-asset fee",
839 btc_usdt(None),
840 fill(Side::Buy, dec!(1), "bnb", dec!(0.001)),
841 ),
842 (
843 "zero base fee",
844 btc_usdt(None),
845 fill(Side::Buy, dec!(1), "btc", Decimal::ZERO),
846 ),
847 (
848 "derivative",
849 perpetual,
850 fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
851 ),
852 ];
853
854 for (case, instrument, fill) in cases {
855 assert_eq!(
856 fill.holding_move(&instrument, Decimal::ZERO, false),
857 None,
858 "{case}"
859 );
860 }
861 }
862
863 #[test]
864 fn holding_move_nets_a_base_fee_on_either_side() {
865 let spot = btc_usdt(None);
866 let cases = [
867 (
868 "buy holds less",
869 fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
870 moved(Side::Buy, dec!(0.999), Decimal::ZERO),
871 ),
872 (
873 "sell delivers more",
874 fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
875 moved(Side::Sell, dec!(1.001), Decimal::ZERO),
876 ),
877 (
878 "rebate adds",
879 fill(Side::Buy, dec!(1), "btc", dec!(-0.001)),
880 moved(Side::Buy, dec!(1.001), Decimal::ZERO),
881 ),
882 (
883 "fee above a buy sells",
884 fill(Side::Buy, dec!(0.001), "btc", dec!(0.003)),
885 moved(Side::Sell, dec!(0.002), Decimal::ZERO),
886 ),
887 (
888 "fee equal to a buy moves nothing",
889 fill(Side::Buy, dec!(0.001), "btc", dec!(0.001)),
890 moved(Side::Buy, Decimal::ZERO, Decimal::ZERO),
891 ),
892 ];
893
894 for (case, fill, expected) in cases {
895 assert_eq!(fill.holding_move(&spot, dec!(2), false), expected, "{case}");
896 }
897 }
898
899 /// The position `held` plus the netted move is what is rounded, toward zero, and the move is
900 /// what lands it there.
901 #[test]
902 fn holding_move_rounds_the_position_toward_zero_to_the_step() {
903 let spot = btc_usdt(Some(dec!(0.01)));
904 let cases = [
905 (
906 "buy from flat",
907 Decimal::ZERO,
908 fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
909 moved(Side::Buy, dec!(0.99), dec!(0.009)),
910 ),
911 // Rounding the move of 1.001 instead would leave a long of 1, more than the 0.999 held.
912 (
913 "sell from a long",
914 dec!(2),
915 fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
916 moved(Side::Sell, dec!(1.01), dec!(0.009)),
917 ),
918 // The fee leaves a short of 0.001, rounded away: base still owed, which no short counts.
919 (
920 "buy back a short",
921 dec!(-1),
922 fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
923 moved(Side::Buy, dec!(1), dec!(-0.001)),
924 ),
925 (
926 "sell opening a short",
927 Decimal::ZERO,
928 fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
929 moved(Side::Sell, dec!(1), dec!(-0.001)),
930 ),
931 (
932 "held off the step",
933 dec!(0.005),
934 fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
935 moved(Side::Buy, dec!(0.995), dec!(0.004)),
936 ),
937 (
938 "lands on the step",
939 dec!(0.001),
940 fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
941 moved(Side::Buy, dec!(0.999), Decimal::ZERO),
942 ),
943 ];
944
945 for (case, held, fill, expected) in cases {
946 assert_eq!(fill.holding_move(&spot, held, false), expected, "{case}");
947 }
948 }
949
950 /// A rounded move of zero keeps the netted move's side: the fill's own, unless the fee
951 /// reversed it.
952 #[test]
953 fn holding_move_rounded_to_nothing_keeps_the_netted_side() {
954 let spot = btc_usdt(Some(dec!(1)));
955 let cases = [
956 (
957 "buy",
958 dec!(1),
959 fill(Side::Buy, dec!(0.5), "btc", dec!(0.001)),
960 moved(Side::Buy, Decimal::ZERO, dec!(0.499)),
961 ),
962 (
963 "fee reverses the buy",
964 dec!(-1),
965 fill(Side::Buy, dec!(0.001), "btc", dec!(0.003)),
966 moved(Side::Sell, Decimal::ZERO, dec!(-0.002)),
967 ),
968 ];
969
970 for (case, held, fill, expected) in cases {
971 assert_eq!(fill.holding_move(&spot, held, false), expected, "{case}");
972 }
973 }
974
975 /// A reduce-only move larger than the opposite position is cut to land it flat; one that
976 /// reduces nothing moves nothing. Either way the cut is the excess, and nothing is rounded.
977 #[test]
978 fn holding_move_cuts_a_reduce_only_move_at_the_position() {
979 let spot = btc_usdt(Some(dec!(0.01)));
980 let cases = [
981 // A repayment buy sized above the short: netted to 10.49, cut at 10.
982 (
983 "buy past a short",
984 dec!(-10),
985 fill(Side::Buy, dec!(10.5), "btc", dec!(0.01)),
986 cut(Side::Buy, dec!(10), dec!(0.49)),
987 ),
988 (
989 "sell past a long",
990 dec!(1),
991 fill(Side::Sell, dec!(1.5), "usdt", dec!(0.1)),
992 cut(Side::Sell, dec!(1), dec!(-0.5)),
993 ),
994 (
995 "buy from flat",
996 Decimal::ZERO,
997 fill(Side::Buy, dec!(1), "usdt", dec!(0.1)),
998 cut(Side::Buy, Decimal::ZERO, dec!(1)),
999 ),
1000 (
1001 "buy onto a long",
1002 dec!(1),
1003 fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
1004 cut(Side::Buy, Decimal::ZERO, dec!(0.999)),
1005 ),
1006 // The fee reverses the buy into a sell, which a short does not reduce.
1007 (
1008 "fee reverses a buy onto a short",
1009 dec!(-1),
1010 fill(Side::Buy, dec!(0.001), "btc", dec!(0.003)),
1011 cut(Side::Sell, Decimal::ZERO, dec!(-0.002)),
1012 ),
1013 ];
1014
1015 for (case, held, fill, expected) in cases {
1016 assert_eq!(fill.holding_move(&spot, held, true), expected, "{case}");
1017 }
1018 }
1019
1020 /// A reduce-only move the position takes whole is moved as any other fill: rounded when its
1021 /// base fee is netted, and `None` when it is not.
1022 #[test]
1023 fn holding_move_leaves_a_reduce_only_move_the_position_takes() {
1024 let spot = btc_usdt(Some(dec!(0.01)));
1025 let cases = [
1026 (
1027 "partial, base fee",
1028 dec!(-2),
1029 fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
1030 moved(Side::Buy, dec!(1), dec!(-0.001)),
1031 ),
1032 (
1033 "to flat exactly, base fee",
1034 dec!(-0.999),
1035 fill(Side::Buy, dec!(1), "btc", dec!(0.001)),
1036 moved(Side::Buy, dec!(0.999), Decimal::ZERO),
1037 ),
1038 (
1039 "partial, rounded",
1040 dec!(2),
1041 fill(Side::Sell, dec!(1), "btc", dec!(0.001)),
1042 moved(Side::Sell, dec!(1.01), dec!(0.009)),
1043 ),
1044 (
1045 "to flat exactly, quote fee",
1046 dec!(1),
1047 fill(Side::Sell, dec!(1), "usdt", dec!(0.1)),
1048 None,
1049 ),
1050 (
1051 "fee equal to a buy moves nothing",
1052 dec!(-1),
1053 fill(Side::Buy, dec!(0.001), "btc", dec!(0.001)),
1054 moved(Side::Buy, Decimal::ZERO, Decimal::ZERO),
1055 ),
1056 // Nothing to cut, so a short already off the step is still rounded.
1057 (
1058 "fee equal to a buy, held off the step",
1059 dec!(-1.005),
1060 fill(Side::Buy, dec!(0.001), "btc", dec!(0.001)),
1061 moved(Side::Buy, dec!(0.005), dec!(-0.005)),
1062 ),
1063 ];
1064
1065 for (case, held, fill, expected) in cases {
1066 assert_eq!(fill.holding_move(&spot, held, true), expected, "{case}");
1067 }
1068 }
1069
1070 /// The cut applies to any instrument kind, where no fee is netted.
1071 #[test]
1072 fn holding_move_cuts_a_reduce_only_derivative_fill() {
1073 let mut perpetual = btc_usdt(None);
1074 perpetual.kind = InstrumentKind::Perpetual(PerpetualContract {
1075 contract_size: Decimal::ONE,
1076 settlement_asset: test_utils::asset("btc"),
1077 });
1078 let fill = fill(Side::Buy, dec!(3), "btc", dec!(0.001));
1079
1080 assert_eq!(
1081 fill.holding_move(&perpetual, dec!(-2), true),
1082 cut(Side::Buy, dec!(2), dec!(1))
1083 );
1084 assert_eq!(fill.holding_move(&perpetual, dec!(-3), true), None);
1085 assert_eq!(fill.holding_move(&perpetual, dec!(-2), false), None);
1086 }
1087
1088 #[test]
1089 fn only_liquidation_adl_and_assignment_are_forced() {
1090 let forced: Vec<_> = [
1091 TradeOrigin::Order,
1092 TradeOrigin::Liquidation,
1093 TradeOrigin::Adl,
1094 TradeOrigin::Assignment,
1095 TradeOrigin::Exercise,
1096 TradeOrigin::Expiry,
1097 TradeOrigin::External,
1098 ]
1099 .into_iter()
1100 .filter(|origin| origin.is_forced())
1101 .collect();
1102 assert_eq!(
1103 forced,
1104 [
1105 TradeOrigin::Liquidation,
1106 TradeOrigin::Adl,
1107 TradeOrigin::Assignment
1108 ]
1109 );
1110 }
1111}