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