rustrade_execution/trade.rs
1use crate::order::id::{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#[derive(
56 Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
57)]
58pub struct Trade<AssetKey, InstrumentKey> {
59 pub id: TradeId,
60 pub order_id: OrderId,
61 pub instrument: InstrumentKey,
62 pub strategy: StrategyId,
63 pub time_exchange: DateTime<Utc>,
64 pub side: Side,
65 pub price: Decimal,
66 /// The size of *this* execution.
67 pub quantity: Decimal,
68 /// The order's cumulative filled quantity as of this execution, as the venue reported it --
69 /// **not** the size of this execution, which is [`Trade::quantity`].
70 ///
71 /// A fill carries two facts: that an execution happened, and what it left the order at. A
72 /// consumer that tracks order state needs both, and they arrive together or not at all. Where
73 /// the venue supplies the second, carrying it here lets the order be advanced from the fill
74 /// itself rather than from a separate message that may never come.
75 ///
76 /// Advancing from a cumulative is idempotent: re-applying the same fill, or applying two out
77 /// of order, cannot double-count, because the receiver takes the greater of what it holds and
78 /// what is reported rather than adding to a running total.
79 ///
80 /// `None` means the venue's fill payload does not carry it, and the order's state must be
81 /// learned some other way -- an order snapshot, or a reconciliation fetch. It is not a claim
82 /// that nothing is filled. Known producers, as of writing:
83 ///
84 /// | Source | Reports it |
85 /// |---|---|
86 /// | Binance Spot / Margin WebSocket `executionReport` | yes (`z`) |
87 /// | 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 |
88 /// | Binance Spot / Margin `fetch_trades` (`myTrades` REST) | no |
89 /// | Alpaca WebSocket `trade_updates` | yes (`order.filled_qty`) |
90 /// | Alpaca account-activities REST | yes (`cum_qty`) |
91 /// | Interactive Brokers `ExecutionData` | yes (`cumulative_quantity`) |
92 /// | Hyperliquid `userFills` | no |
93 /// | `SimulatedVenue` | yes |
94 ///
95 /// `#[serde(default)]` so trades serialised before this field existed still deserialise, as
96 /// `None`.
97 #[serde(default)]
98 pub order_filled_quantity: Option<Decimal>,
99 pub fees: AssetFees<AssetKey>,
100}
101
102impl<AssetKey, InstrumentKey> Trade<AssetKey, InstrumentKey> {
103 pub fn value_quote(&self) -> Decimal {
104 self.price * self.quantity.abs()
105 }
106}
107
108impl<AssetKey, InstrumentKey> Display for Trade<AssetKey, InstrumentKey>
109where
110 AssetKey: Display,
111 InstrumentKey: Display,
112{
113 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
114 write!(
115 f,
116 "{{ instrument: {}, side: {}, price: {}, quantity: {}, time: {} }}",
117 self.instrument, self.side, self.price, self.quantity, self.time_exchange
118 )
119 }
120}
121
122#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
123pub struct AssetFees<AssetKey> {
124 pub asset: AssetKey,
125 pub fees: Decimal,
126 /// Fee value in quote currency when computable.
127 /// - `Some(fees)` if fee asset == quote asset
128 /// - `Some(fees * price)` if fee asset == base asset (computed by indexer)
129 /// - `None` if fee asset is third-party (e.g., BNB) — requires external price data
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub fees_quote: Option<Decimal>,
132}
133
134impl<AssetKey> AssetFees<AssetKey> {
135 pub fn new(asset: AssetKey, fees: Decimal, fees_quote: Option<Decimal>) -> Self {
136 Self {
137 asset,
138 fees,
139 fees_quote,
140 }
141 }
142}
143
144impl AssetFees<QuoteAsset> {
145 /// Construct fees already denominated in quote asset.
146 /// Sets `fees_quote = Some(fees)` since no conversion needed.
147 pub fn quote_fees(fees: Decimal) -> Self {
148 Self {
149 asset: QuoteAsset,
150 fees,
151 fees_quote: Some(fees),
152 }
153 }
154}
155
156impl Default for AssetFees<QuoteAsset> {
157 fn default() -> Self {
158 Self {
159 asset: QuoteAsset,
160 fees: Decimal::ZERO,
161 fees_quote: Some(Decimal::ZERO),
162 }
163 }
164}
165
166impl<AssetKey> Default for AssetFees<Option<AssetKey>> {
167 fn default() -> Self {
168 Self {
169 asset: None,
170 fees: Decimal::ZERO,
171 fees_quote: None,
172 }
173 }
174}