Skip to main content

rustrade_execution/
trade.rs

1use crate::order::id::{ClientOrderId, OrderId, StrategyId};
2use chrono::{DateTime, Utc};
3use derive_more::{Constructor, From};
4use rust_decimal::Decimal;
5use rustrade_instrument::{Side, asset::QuoteAsset};
6use serde::{Deserialize, Serialize};
7use smol_str::SmolStr;
8use std::fmt::{Display, Formatter};
9
10/// A venue's own identifier for one trade, carried through opaquely.
11///
12/// # Uniqueness is the venue's to define, and is often narrower than global
13///
14/// This library does not mint these — it passes through whatever the venue said, so what an id
15/// distinguishes is whatever that venue distinguishes. Binance's are **per symbol**, which is why
16/// this crate's own deduplication keys on the instrument alongside the id rather than on the id
17/// alone; Hyperliquid reports a transaction hash that can cover several matches at once. A
18/// consumer that needs a key should use `(exchange, instrument, TradeId)` and not assume any part
19/// of it is redundant.
20///
21/// [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) mints ids unique within one venue
22/// instance for its lifetime — and, because each venue counts from zero, **not** across the
23/// several a multi-exchange backtest holds. That is the same scoping a real venue gives, for the
24/// same reason.
25///
26/// # `Ord` is byte order, not time order
27///
28/// The derived comparison is lexicographic over the underlying string, so `"10" < "9"`. It exists
29/// so this type can key a map or a set; it carries no chronological meaning and is not a stand-in
30/// for one. Order trades by [`Trade::time_exchange`].
31///
32/// # Finding the order behind a trade
33///
34/// Use [`Trade::order_id`], which is typed. Do **not** parse this id: one order can print more
35/// than once, several venues embed structure of their own here (IBKR's `execution_id` is
36/// dot-separated, Alpaca's can be a UUID), and no format is common to them.
37#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, From)]
38pub struct TradeId<T = SmolStr>(pub T);
39
40impl<T> Display for TradeId<T>
41where
42    T: Display,
43{
44    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
45        Display::fmt(&self.0, f)
46    }
47}
48
49impl TradeId {
50    pub fn new<S: AsRef<str>>(id: S) -> Self {
51        Self(SmolStr::new(id))
52    }
53}
54
55/// One execution: a fill the venue reported, or one the engine settled itself.
56///
57/// `#[non_exhaustive]`, so a field can be added without breaking a caller. Outside this crate,
58/// build one with [`Trade::new`] and the `with_*` methods, and change a copy by assigning its
59/// public fields.
60#[non_exhaustive]
61#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
62pub struct Trade<AssetKey, InstrumentKey> {
63    pub id: TradeId,
64    pub order_id: OrderId,
65    pub instrument: InstrumentKey,
66    pub strategy: StrategyId,
67    pub time_exchange: DateTime<Utc>,
68    pub side: Side,
69    pub price: Decimal,
70    /// The size of *this* execution.
71    pub quantity: Decimal,
72    /// The order's cumulative filled quantity as of this execution, as the venue reported it --
73    /// **not** the size of this execution, which is [`Trade::quantity`].
74    ///
75    /// A fill carries two facts: that an execution happened, and what it left the order at. A
76    /// consumer that tracks order state needs both, and they arrive together or not at all. Where
77    /// the venue supplies the second, carrying it here lets the order be advanced from the fill
78    /// itself rather than from a separate message that may never come.
79    ///
80    /// Advancing from a cumulative is idempotent: re-applying the same fill, or applying two out
81    /// of order, cannot double-count, because the receiver takes the greater of what it holds and
82    /// what is reported rather than adding to a running total.
83    ///
84    /// `None` means the venue's fill payload does not carry it, and the order's state must be
85    /// learned some other way -- an order snapshot, or a reconciliation fetch. It is not a claim
86    /// that nothing is filled. Known producers, as of writing:
87    ///
88    /// | Source | Reports it |
89    /// |---|---|
90    /// | Binance Spot / Margin WebSocket `executionReport` | yes (`z`) |
91    /// | 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 |
92    /// | Binance Spot / Margin `fetch_trades` (`myTrades` REST) | no |
93    /// | Alpaca WebSocket `trade_updates` | yes (`order.filled_qty`) |
94    /// | Alpaca account-activities REST | yes (`cum_qty`) |
95    /// | Interactive Brokers `ExecutionData` | yes (`cumulative_quantity`) |
96    /// | Hyperliquid `userFills` | no |
97    /// | `SimulatedVenue` | yes |
98    ///
99    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
100    /// `None`.
101    #[serde(default)]
102    pub order_filled_quantity: Option<Decimal>,
103    pub fees: AssetFees<AssetKey>,
104    /// Why the fill happened, as far as its producer can tell. See [`TradeOrigin`] for what each
105    /// venue reports.
106    ///
107    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
108    /// [`TradeOrigin::Order`].
109    #[serde(default)]
110    pub origin: TradeOrigin,
111    /// The side of the position the fill applies to, when the venue names one: [`Side::Buy`] for
112    /// a long, [`Side::Sell`] for a short.
113    ///
114    /// A venue in hedge mode, holding a long and a short in one instrument at once, names the
115    /// position each fill belongs to (Binance USD-M futures' `LONG` and `SHORT`). The engine in
116    /// `OmsMode::Hedging` checks a forced fill, which matches none of its orders, against it: a
117    /// forced fill must reduce the position the venue names. It only ever confirms the side a
118    /// reducing fill already implies, the side opposite its own, and does not choose among
119    /// several of the engine's positions on that side.
120    ///
121    /// It takes part in the derived `Eq`, `Hash` and `Ord`, as every field does: deduplicate fills
122    /// by instrument and [`id`](Self::id), not by the whole `Trade`.
123    /// `None` when the venue names none, as every client in this crate does as of writing: none
124    /// trades a venue in hedge mode.
125    ///
126    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
127    /// `None`.
128    #[serde(default)]
129    pub position_side: Option<Side>,
130    /// The client order id of the order this fill belongs to, as the venue reported it with the
131    /// fill.
132    ///
133    /// The id is chosen before the order is sent, so a consumer can attribute a fill by it at
134    /// once. That includes a fill of an order placed outside the engine on the same account,
135    /// which no order the engine knows matches: [`order_id`](Self::order_id) is the venue's, and
136    /// the fill can arrive before the venue's response to the order names it, or long after, in a
137    /// recovery read. A consumer that trades an account outside the engine can mark those orders'
138    /// client order ids, by a prefix say, and keep their fills from the engine.
139    ///
140    /// `None` means the fill's payload did not carry one. It is not a claim that the order has
141    /// none. Known producers, as of writing:
142    ///
143    /// | Source | Reports it |
144    /// |---|---|
145    /// | Binance Spot / Margin WebSocket `executionReport` | yes (`c`) |
146    /// | 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 |
147    /// | Binance Spot / Margin `fetch_trades` (`myTrades` REST) | no |
148    /// | Alpaca WebSocket `trade_updates` | yes (`order.client_order_id`) |
149    /// | Alpaca account-activities REST (fill recovery, `fetch_trades`), `fetch_option_events` | no |
150    /// | Interactive Brokers executions | yes, the order reference, which this crate's client sets to the client order id; `None` for an order without one |
151    /// | Hyperliquid `userFills` | yes, the client order id the order's cloid maps to; `None` for an order placed without a cloid |
152    /// | `SimulatedVenue` | yes |
153    /// | The `rustrade` engine's expiry settlement | no |
154    ///
155    /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
156    /// `None`.
157    #[serde(default)]
158    pub client_order_id: Option<ClientOrderId>,
159}
160
161impl<AssetKey, InstrumentKey> Trade<AssetKey, InstrumentKey> {
162    /// A trade of [`TradeOrigin::Order`]. Set another origin with
163    /// [`with_origin`](Self::with_origin).
164    #[allow(clippy::too_many_arguments)] // One argument per field a fill always has.
165    pub fn new(
166        id: TradeId,
167        order_id: OrderId,
168        instrument: InstrumentKey,
169        strategy: StrategyId,
170        time_exchange: DateTime<Utc>,
171        side: Side,
172        price: Decimal,
173        quantity: Decimal,
174        order_filled_quantity: Option<Decimal>,
175        fees: AssetFees<AssetKey>,
176    ) -> Self {
177        Self {
178            id,
179            order_id,
180            instrument,
181            strategy,
182            time_exchange,
183            side,
184            price,
185            quantity,
186            order_filled_quantity,
187            fees,
188            origin: TradeOrigin::Order,
189            position_side: None,
190            client_order_id: None,
191        }
192    }
193
194    /// This trade with its [`origin`](Self::origin) set.
195    #[must_use]
196    pub fn with_origin(self, origin: TradeOrigin) -> Self {
197        Self { origin, ..self }
198    }
199
200    /// This trade with the [`position_side`](Self::position_side) the venue named.
201    #[must_use]
202    pub fn with_position_side(self, position_side: Side) -> Self {
203        Self {
204            position_side: Some(position_side),
205            ..self
206        }
207    }
208
209    /// This trade with the [`client_order_id`](Self::client_order_id) the venue reported.
210    #[must_use]
211    pub fn with_client_order_id(self, client_order_id: ClientOrderId) -> Self {
212        Self {
213            client_order_id: Some(client_order_id),
214            ..self
215        }
216    }
217
218    pub fn value_quote(&self) -> Decimal {
219        self.price * self.quantity.abs()
220    }
221}
222
223/// What one [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades) call
224/// read of the span it was asked for.
225///
226/// A venue reads a span in bounded calls, so one call may stop before the span's end. `resume`
227/// says whether this one did:
228/// - `None`: every fill in the span the venue still holds is in `trades`.
229/// - `Some(t)`: the call stopped at its bound, and fills from `t` on may be missing from
230///   `trades`. Read on from `start = t` with the same end. That read can return again fills this
231///   one returned at or after `t`, so match them by instrument and [`TradeId`].
232///
233/// `trades` can be empty while `resume` is `Some`: a venue that reads every instrument's fills
234/// and filters them afterwards can spend a whole call on other instruments'. A caller that takes
235/// `trades` without reading on from `resume` can miss fills.
236#[non_exhaustive]
237#[must_use = "`resume` says whether the span was read to its end"]
238#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
239pub struct TradesRead<AssetKey, InstrumentKey> {
240    /// The fills read, in the span and for the requested instruments.
241    pub trades: Vec<Trade<AssetKey, InstrumentKey>>,
242    /// Where to read on from, or `None` when the read reached the span's end.
243    pub resume: Option<DateTime<Utc>>,
244}
245
246impl<AssetKey, InstrumentKey> TradesRead<AssetKey, InstrumentKey> {
247    /// A read that reached the span's end.
248    pub fn complete(trades: Vec<Trade<AssetKey, InstrumentKey>>) -> Self {
249        Self {
250            trades,
251            resume: None,
252        }
253    }
254}
255
256impl<AssetKey, InstrumentKey> Display for Trade<AssetKey, InstrumentKey>
257where
258    AssetKey: Display,
259    InstrumentKey: Display,
260{
261    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
262        write!(
263            f,
264            "{{ instrument: {}, side: {}, price: {}, quantity: {}, time: {}",
265            self.instrument, self.side, self.price, self.quantity, self.time_exchange
266        )?;
267        // Shown only when it is not the default, so a forced fill stands out in a log line.
268        if self.origin != TradeOrigin::Order {
269            write!(f, ", origin: {}", self.origin)?;
270        }
271        write!(f, " }}")
272    }
273}
274
275/// Why a [`Trade`] happened, as far as its producer can tell.
276///
277/// # `Order` is the default, not a guarantee
278///
279/// [`Order`](Self::Order) means the producer reported no other origin. Most venues mark some
280/// origins and not others, so a fill can be `Order` and still be forced, or still be for an order
281/// this client did not place. What each producer reports, as of writing:
282///
283/// | Source | Reports |
284/// |---|---|
285/// | Interactive Brokers | [`Liquidation`](Self::Liquidation), [`External`](Self::External) (opt-in), `Order` |
286/// | Binance Spot / Margin | `Order` only |
287/// | Hyperliquid | `Order` only |
288/// | Alpaca | `Order` on fills; [`Expiry`](Self::Expiry), [`Exercise`](Self::Exercise), [`Assignment`](Self::Assignment) and [`External`](Self::External) from `AlpacaClient::fetch_option_events` |
289/// | [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) | `Order` only |
290/// | The `rustrade` engine's expiry settlement | [`Expiry`](Self::Expiry) |
291///
292/// - **Interactive Brokers** flags a fill of an IB-initiated liquidation. Another API client's
293///   fill is reported, as `External`, only when `IbkrConfig::other_clients_fills` opts in.
294/// - **Binance** marks no forced liquidation in its Spot or Margin order stream: its liquidation
295///   markers are documented for futures only. Every fill on the account arrives, whoever placed
296///   its order. `BinanceMargin::fetch_forced_liquidations` reads the margin orders Binance placed
297///   to liquidate the account, for a caller to match against its fills.
298/// - **Hyperliquid** fills carry a liquidation marker, but the SDK this crate uses drops it.
299/// - **Alpaca** marks no liquidation. Option assignment, exercise and expiry are account
300///   activities, which this crate does not read.
301/// - The **simulated venue** models no liquidation.
302///
303/// A forced variant takes precedence over [`External`](Self::External): a liquidation is
304/// reported as a liquidation whoever placed the order it closed.
305///
306/// # Serialised form and ordering
307///
308/// Serialised as the variant's name (`"Liquidation"`), as [`Side`] is, while `Display` writes it
309/// in lower case (`liquidation`). The names are a stored format: they are not renamed, and new
310/// variants are added after the existing ones. A trade with an origin a build does not know does
311/// not deserialise in that build.
312///
313/// `Ord` follows declaration order. It exists so [`Trade`] can derive `Ord`, and carries no
314/// meaning beyond that.
315#[non_exhaustive]
316#[derive(
317    Debug, Copy, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize,
318)]
319pub enum TradeOrigin {
320    /// A fill of an order, with no other origin reported. See the type's docs for why this is not
321    /// a guarantee that this client placed the order.
322    #[default]
323    Order,
324    /// The venue or broker closed the position because the account fell short of margin.
325    Liquidation,
326    /// The venue reduced the position against a counterparty it was liquidating
327    /// (auto-deleveraging).
328    Adl,
329    /// An option this account was short was assigned.
330    Assignment,
331    /// An option this account held was exercised, by the holder or automatically.
332    Exercise,
333    /// The instrument expired and the position was settled.
334    Expiry,
335    /// A fill of an order that another client, such as another API connection or the venue's own
336    /// trading screen, placed on the same account.
337    External,
338}
339
340impl TradeOrigin {
341    /// Whether the position changed without the account placing an order for it: a
342    /// [`Liquidation`](Self::Liquidation), an [`Adl`](Self::Adl) or an
343    /// [`Assignment`](Self::Assignment).
344    ///
345    /// An [`Exercise`](Self::Exercise) is the holder's right, and an [`Expiry`](Self::Expiry) is
346    /// the instrument's end, so neither counts.
347    pub fn is_forced(self) -> bool {
348        matches!(self, Self::Liquidation | Self::Adl | Self::Assignment)
349    }
350}
351
352impl Display for TradeOrigin {
353    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
354        f.write_str(match self {
355            Self::Order => "order",
356            Self::Liquidation => "liquidation",
357            Self::Adl => "adl",
358            Self::Assignment => "assignment",
359            Self::Exercise => "exercise",
360            Self::Expiry => "expiry",
361            Self::External => "external",
362        })
363    }
364}
365
366/// A venue's notice that a trade it reported earlier was busted or corrected.
367///
368/// Sent as [`AccountEventKind::TradeAmended`](crate::AccountEventKind::TradeAmended). The
369/// [`Trade`] it amends was delivered as reported and is not withdrawn: positions, PnL and fees
370/// built on it stay wrong until the consumer applies this. Find it by instrument and
371/// [`original`](Self::original), reverse it, and apply the [`kind`](Self::kind)'s replacement, if
372/// any. The library does not act on it beyond reporting it, and the engine only logs it.
373///
374/// A replacement can itself be amended later. That amendment names the replacement's id as its
375/// `original`, so follow amendments by id, one at a time.
376///
377/// # Known limitations
378///
379/// - An order's cumulative filled quantity is not lowered by a bust:
380///   [`Open::is_superseded_by`](crate::order::state::Open::is_superseded_by) refuses a lower
381///   cumulative, so the order stays on what it reported before.
382/// - Alpaca's fill recovery after a reconnect reads fills only, so an Alpaca amendment sent while
383///   the stream was disconnected is not reported. IBKR's recovery reads corrections too.
384///
385/// # Delivery
386///
387/// Apply an amendment idempotently: reverse a given `original` once, and apply a replacement
388/// once by its [`TradeId`]. Whether a venue can send one amendment twice, such as around a
389/// reconnect, is not known for every producer. The library delivers each IBKR correction once
390/// per account stream, but does not deduplicate Alpaca's amendments.
391///
392/// Known producers, as of writing:
393/// - Alpaca's `trade_updates` `trade_bust` and `trade_correct`;
394/// - IBKR's corrected executions, as [`Corrected`](TradeAmendmentKind::Corrected) only. IB
395///   documents no busts.
396#[non_exhaustive]
397#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
398pub struct TradeAmendment<AssetKey, InstrumentKey> {
399    /// The instrument of the trade amended.
400    pub instrument: InstrumentKey,
401    /// The order whose trade was amended.
402    pub order_id: OrderId,
403    /// When the venue amended the trade, or when it was received where the venue does not say.
404    pub time_exchange: DateTime<Utc>,
405    /// The [`TradeId`] of the trade amended, as the venue named it. `None` when the venue's notice
406    /// did not name it: match it to a trade of [`order_id`](Self::order_id) yourself, or
407    /// reconcile with
408    /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades).
409    pub original: Option<TradeId>,
410    /// What the venue did to the trade.
411    pub kind: TradeAmendmentKind<AssetKey, InstrumentKey>,
412}
413
414/// What a [`TradeAmendment`] did to the trade.
415#[non_exhaustive]
416#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)]
417pub enum TradeAmendmentKind<AssetKey, InstrumentKey> {
418    /// The trade was cancelled: it did not happen.
419    Busted {
420        /// The quantity busted, as a magnitude, when the venue said. A venue that reports it as
421        /// a negative reversal is read as its absolute value.
422        quantity: Option<Decimal>,
423    },
424    /// The trade was replaced, in full.
425    Corrected {
426        /// The trade as corrected: its price and quantity are the corrected values, not
427        /// differences from the original's. It has its own [`TradeId`], which a later amendment
428        /// of it names.
429        replacement: Trade<AssetKey, InstrumentKey>,
430    },
431    /// The trade was corrected, but the venue's notice lacked what a replacement [`Trade`] needs.
432    /// What it did carry is here. Reconcile the trade with
433    /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades).
434    CorrectedUnresolved {
435        /// The id of the replacement, which a later amendment of it names.
436        id: Option<TradeId>,
437        /// The corrected price, if the notice had one.
438        price: Option<Decimal>,
439        /// The corrected quantity, if the notice had one.
440        quantity: Option<Decimal>,
441    },
442}
443
444#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
445pub struct AssetFees<AssetKey> {
446    pub asset: AssetKey,
447    pub fees: Decimal,
448    /// Fee value in quote currency when computable.
449    /// - `Some(fees)` if fee asset == quote asset
450    /// - `Some(fees * price)` if fee asset == base asset (computed by indexer)
451    /// - `None` if fee asset is third-party (e.g., BNB), which needs external price data. Also
452    ///   `None` when the indexer zeroed a fee whose asset was not in its map (see
453    ///   [`AccountEventIndexer::trade`](crate::indexer::AccountEventIndexer::trade)).
454    ///
455    /// The engine's position PnL counts only this quote equivalent. A fee with `None` is left
456    /// out of PnL, and its raw [`fees`](Self::fees) stays recorded on the position. A consumer that
457    /// wants it counted prices it. On a position's running fee totals, this is the sum of the
458    /// quote equivalents its fees had, and `None` only while none had one.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub fees_quote: Option<Decimal>,
461}
462
463impl<AssetKey> AssetFees<AssetKey> {
464    pub fn new(asset: AssetKey, fees: Decimal, fees_quote: Option<Decimal>) -> Self {
465        Self {
466            asset,
467            fees,
468            fees_quote,
469        }
470    }
471}
472
473impl AssetFees<QuoteAsset> {
474    /// Construct fees already denominated in quote asset.
475    /// Sets `fees_quote = Some(fees)` since no conversion needed.
476    pub fn quote_fees(fees: Decimal) -> Self {
477        Self {
478            asset: QuoteAsset,
479            fees,
480            fees_quote: Some(fees),
481        }
482    }
483}
484
485impl Default for AssetFees<QuoteAsset> {
486    fn default() -> Self {
487        Self {
488            asset: QuoteAsset,
489            fees: Decimal::ZERO,
490            fees_quote: Some(Decimal::ZERO),
491        }
492    }
493}
494
495impl<AssetKey> Default for AssetFees<Option<AssetKey>> {
496    fn default() -> Self {
497        Self {
498            asset: None,
499            fees: Decimal::ZERO,
500            fees_quote: None,
501        }
502    }
503}
504
505#[cfg(test)]
506#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
507mod tests {
508    use super::*;
509    use rustrade_instrument::{
510        asset::name::AssetNameExchange, instrument::name::InstrumentNameExchange,
511    };
512
513    fn trade() -> Trade<AssetNameExchange, InstrumentNameExchange> {
514        Trade::new(
515            TradeId::new("t-1"),
516            OrderId::new("o-1"),
517            InstrumentNameExchange::new("btc_usdt"),
518            StrategyId::unknown(),
519            DateTime::<Utc>::MIN_UTC,
520            Side::Sell,
521            Decimal::ONE,
522            Decimal::TWO,
523            None,
524            AssetFees::new(AssetNameExchange::from("usdt"), Decimal::ZERO, None),
525        )
526    }
527
528    #[test]
529    fn a_trade_is_of_an_order_unless_given_another_origin() {
530        assert_eq!(trade().origin, TradeOrigin::Order);
531        assert_eq!(
532            trade().with_origin(TradeOrigin::Adl).origin,
533            TradeOrigin::Adl
534        );
535    }
536
537    /// A trade serialised before `origin` existed still loads, as an order's.
538    #[test]
539    fn a_trade_without_an_origin_deserialises_as_an_orders() {
540        let mut json = serde_json::to_value(trade().with_origin(TradeOrigin::External)).unwrap();
541        assert_eq!(json["origin"], "External");
542        json.as_object_mut().unwrap().remove("origin");
543
544        let read: Trade<AssetNameExchange, InstrumentNameExchange> =
545            serde_json::from_value(json).unwrap();
546        assert_eq!(read, trade());
547    }
548
549    #[test]
550    fn display_names_the_origin_only_when_it_is_not_an_orders() {
551        assert!(!trade().to_string().contains("origin"));
552        assert!(
553            trade()
554                .with_origin(TradeOrigin::Liquidation)
555                .to_string()
556                .ends_with(", origin: liquidation }")
557        );
558    }
559
560    #[test]
561    fn only_liquidation_adl_and_assignment_are_forced() {
562        let forced: Vec<_> = [
563            TradeOrigin::Order,
564            TradeOrigin::Liquidation,
565            TradeOrigin::Adl,
566            TradeOrigin::Assignment,
567            TradeOrigin::Exercise,
568            TradeOrigin::Expiry,
569            TradeOrigin::External,
570        ]
571        .into_iter()
572        .filter(|origin| origin.is_forced())
573        .collect();
574        assert_eq!(
575            forced,
576            [
577                TradeOrigin::Liquidation,
578                TradeOrigin::Adl,
579                TradeOrigin::Assignment
580            ]
581        );
582    }
583}