rustrade_execution/lib.rs
1#![forbid(unsafe_code)]
2#![deny(
3 clippy::unwrap_used,
4 clippy::expect_used,
5 clippy::cast_possible_truncation,
6 clippy::cast_sign_loss
7)]
8#![warn(
9 unused,
10 clippy::cognitive_complexity,
11 unused_crate_dependencies,
12 unused_extern_crates,
13 clippy::unused_self,
14 clippy::useless_let_if_seq,
15 missing_debug_implementations,
16 rust_2018_idioms
17)]
18// Inherited from the barter-rs upstream: the generic, trait-heavy public API legitimately produces
19// complex nested types and many-argument constructors, and `type_alias_bounds` fires on
20// documentation-bearing alias bounds kept intentionally. Denying these adds churn without improving
21// the API.
22#![allow(clippy::type_complexity, clippy::too_many_arguments, type_alias_bounds)]
23
24//! # Barter-Execution
25//! Stream private account data from financial venues, and execute (live or mock) orders. Also provides
26//! a feature rich MockExchange and MockExecutionClient to assist with backtesting and paper-trading.
27//!
28//! **It is:**
29//! * **Easy**: ExecutionClient trait provides a unified and simple language for interacting with exchanges.
30//! * **Normalised**: Allow your strategy to communicate with every real or MockExchange using the same interface.
31//! * **Extensible**: Barter-Execution is highly extensible, making it easy to contribute by adding new exchange integrations!
32//!
33//! See `README.md` for more information and examples.
34
35// Silence unused_crate_dependencies for dev-dependencies used only in tests
36#[cfg(test)]
37use serial_test as _;
38#[cfg(test)]
39use temp_env as _;
40#[cfg(test)]
41use tracing_subscriber as _;
42#[cfg(test)]
43use wiremock as _;
44
45use crate::{
46 balance::{AssetBalance, AssetBalanceUpdate},
47 error::StreamTerminationReason,
48 order::{Order, OrderSnapshot, request::OrderResponseCancel},
49 position::PositionReport,
50 trade::Trade,
51};
52use chrono::{DateTime, Utc};
53use derive_more::{Constructor, From};
54use order::state::OrderState;
55use rust_decimal::Decimal;
56use rustrade_instrument::{
57 asset::{AssetIndex, name::AssetNameExchange},
58 exchange::{ExchangeId, ExchangeIndex},
59 instrument::{InstrumentIndex, name::InstrumentNameExchange},
60};
61use rustrade_integration::collection::snapshot::Snapshot;
62use serde::{Deserialize, Serialize};
63// Only `emit_stream_terminated` (and its tests) name `mpsc`, both gated to the venue features —
64// gate the import the same way so the default-empty feature set does not warn it as unused.
65#[cfg(any(
66 feature = "alpaca",
67 feature = "binance",
68 feature = "ibkr",
69 feature = "hyperliquid"
70))]
71use tokio::sync::mpsc;
72
73pub mod balance;
74pub mod client;
75pub mod error;
76pub mod exchange;
77pub mod fee;
78pub use fee::{
79 FeeModel, FeeModelConfig, Liquidity, PerContractFeeModel, PercentageFeeModel, ZeroFeeModel,
80};
81pub mod fill;
82pub use fill::{
83 BidAskFillModel, FillContext, FillModel, LastPriceFillModel, MidpointFillModel, SimFillConfig,
84};
85pub mod indexer;
86pub mod map;
87pub mod market;
88pub use market::{MarketDepth, MarketSnapshot};
89pub mod order;
90pub mod position;
91pub mod trade;
92
93/// Convenient type alias for an [`AccountEvent`] keyed with [`ExchangeId`],
94/// [`AssetNameExchange`], and [`InstrumentNameExchange`].
95pub type UnindexedAccountEvent =
96 AccountEvent<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
97
98/// Convenient type alias for an [`AccountSnapshot`] keyed with [`ExchangeId`],
99/// [`AssetNameExchange`], and [`InstrumentNameExchange`].
100pub type UnindexedAccountSnapshot =
101 AccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
102
103#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
104pub struct AccountEvent<
105 ExchangeKey = ExchangeIndex,
106 AssetKey = AssetIndex,
107 InstrumentKey = InstrumentIndex,
108> {
109 pub exchange: ExchangeKey,
110 pub kind: AccountEventKind<ExchangeKey, AssetKey, InstrumentKey>,
111}
112
113impl<ExchangeKey, AssetKey, InstrumentKey> AccountEvent<ExchangeKey, AssetKey, InstrumentKey> {
114 pub fn new<K>(exchange: ExchangeKey, kind: K) -> Self
115 where
116 K: Into<AccountEventKind<ExchangeKey, AssetKey, InstrumentKey>>,
117 {
118 Self {
119 exchange,
120 kind: kind.into(),
121 }
122 }
123
124 /// Named constructor for the terminal [`StreamTerminated`](AccountEventKind::StreamTerminated)
125 /// event carrying the given [`StreamTerminationReason`].
126 pub(crate) fn stream_terminated(
127 exchange: ExchangeKey,
128 reason: StreamTerminationReason,
129 ) -> Self {
130 Self {
131 exchange,
132 kind: AccountEventKind::StreamTerminated(reason),
133 }
134 }
135}
136
137/// Best-effort in-band emit of a terminal
138/// [`StreamTerminated`](AccountEventKind::StreamTerminated) on the account-event channel, sent just
139/// before a venue's stream task exits so stream death is delivered in-band rather than inferred from
140/// channel EOF.
141///
142/// Shared by the channel-based venue connectors (Binance spot/margin, Alpaca, IBKR, Hyperliquid)
143/// so the single send of the terminal event lives in one place, at the abstraction level where
144/// [`AccountEvent`] is defined rather than inside any one vendor module. (The Mock client yields
145/// into a `Stream` rather than an mpsc channel, so it builds the terminal event via
146/// `UnindexedAccountEvent::stream_terminated` directly and does not funnel through this helper.)
147/// The send is best-effort: if the consumer already dropped the receiver it is a silent no-op —
148/// which is the consumer-initiated-drop case, deliberately *not* signalled (there is no one left
149/// to deliver to).
150//
151// Gated to the venues that send terminal events through an mpsc channel from a spawned stream task,
152// so the crate's default-empty feature set does not warn this as unused (mirrors `parse_env_bool`
153// below). The Mock client is always compiled but is excluded by design — see the note above.
154#[cfg(any(
155 feature = "alpaca",
156 feature = "binance",
157 feature = "ibkr",
158 feature = "hyperliquid"
159))]
160pub(crate) fn emit_stream_terminated(
161 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
162 exchange: ExchangeId,
163 reason: StreamTerminationReason,
164) {
165 let _ = tx.send(UnindexedAccountEvent::stream_terminated(exchange, reason));
166}
167
168/// Parse a boolean environment-variable value using the single cross-venue policy.
169///
170/// Accepts only `"true"` / `"false"` (case-insensitive, surrounding whitespace trimmed); every other
171/// value returns `None` so the caller can surface an explicit "must be true or false" error rather
172/// than silently coercing. Deliberately does **not** accept `"1"`/`"0"` or other truthy spellings —
173/// one strict, unambiguous spelling shared by every venue's `from_env` (Alpaca `ALPACA_PAPER`,
174/// Binance `BINANCE_TESTNET`, Hyperliquid `HYPERLIQUID_TESTNET`) keeps the network-selection toggle
175/// impossible to misread.
176//
177// Gated to the venues that read env-bool toggles so the crate's default-empty feature set does not
178// warn this as unused.
179#[cfg(any(feature = "alpaca", feature = "binance", feature = "hyperliquid"))]
180pub(crate) fn parse_env_bool(value: &str) -> Option<bool> {
181 let trimmed = value.trim();
182 if trimmed.eq_ignore_ascii_case("true") {
183 Some(true)
184 } else if trimmed.eq_ignore_ascii_case("false") {
185 Some(false)
186 } else {
187 None
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, From)]
192#[non_exhaustive]
193pub enum AccountEventKind<ExchangeKey, AssetKey, InstrumentKey> {
194 /// Full [`AccountSnapshot`] - replaces all existing state.
195 Snapshot(AccountSnapshot<ExchangeKey, AssetKey, InstrumentKey>),
196
197 /// Single [`AssetBalance`] snapshot - replaces existing balance state.
198 ///
199 /// Sourced from a REST account snapshot: carries the **full** balance including any per-asset
200 /// margin debt (`borrowed`/`interest`). This is the authoritative source for debt totals.
201 BalanceSnapshot(Snapshot<AssetBalance<AssetKey>>),
202
203 /// Single [`AssetBalanceUpdate`] - applies a WS partial (`free`/`locked` only).
204 ///
205 /// Sourced from an exchange WS user-data stream (e.g. Binance `outboundAccountPosition`). It
206 /// carries **no** margin debt, so applying it updates `free`/`locked` while **preserving** any
207 /// existing [`MarginDetails`](balance::MarginDetails) — debt cannot be silently clobbered by a
208 /// stream update. Debt totals remain as fresh as the last [`BalanceSnapshot`](Self::BalanceSnapshot).
209 BalanceStreamUpdate(Snapshot<AssetBalanceUpdate<AssetKey>>),
210
211 /// Live per-instrument isolated-margin balance update (`free`/`locked` per side).
212 ///
213 /// The stream counterpart of [`InstrumentAccountSnapshot::isolated`] for venues with per-pair
214 /// isolated sub-accounts (e.g. Binance isolated margin). Carries a point-in-time `free`/`locked`
215 /// **snapshot** for the pair's `base` and `quote` assets — NOT a delta — keyed by instrument
216 /// rather than asset, because isolated balances are per-`(pair, asset)` and cannot be folded
217 /// into the asset-keyed balance state without collision (see [`InstrumentBalanceUpdate`]).
218 ///
219 /// The engine deliberately does **not** store this (the per-asset balance state is informational
220 /// only and the engine never reads it for sizing/gating); a consumer reads it off the account
221 /// event feed. Debt totals stay as fresh as the last
222 /// [`BalanceSnapshot`](Self::BalanceSnapshot) per the debt-freshness contract.
223 InstrumentBalanceUpdate(InstrumentBalanceUpdate<AssetKey, InstrumentKey>),
224
225 /// Single [`Order`] snapshot - used to upsert existing order state if it's more recent.
226 ///
227 /// This variant covers general order updates, and open order responses.
228 OrderSnapshot(Snapshot<Order<ExchangeKey, InstrumentKey, OrderState<AssetKey, InstrumentKey>>>),
229
230 /// Response to an [`OrderRequestCancel<ExchangeKey, InstrumentKey>`](order::request::OrderRequestOpen).
231 OrderCancelled(OrderResponseCancel<ExchangeKey, AssetKey, InstrumentKey>),
232
233 /// [`Order<ExchangeKey, InstrumentKey, Open>`] partial or full-fill.
234 ///
235 /// The fee asset (`AssetKey`) may be the quote asset, base asset, or a third-party
236 /// asset (e.g., BNB on Binance). Use `fees.fees_quote` for quote-equivalent value
237 /// when available.
238 Trade(Trade<AssetKey, InstrumentKey>),
239
240 /// The account event stream has ended; no further events will arrive on it.
241 ///
242 /// This is the in-band, programmatic signal that a stream died — delivered on the **same**
243 /// account feed as every other event rather than being inferred from channel EOF or read from
244 /// logs. The [`StreamTerminationReason`] distinguishes a venue that exhausted its reconnect
245 /// budget from an unrecoverable error, so the consumer can apply its own recovery policy
246 /// (re-establish the stream, re-sync via REST, halt trading). The library reports *that* and
247 /// *why* the stream ended; it does not prescribe the response.
248 ///
249 /// Only terminations deliverable while the consumer is still listening are signalled: a
250 /// consumer-initiated drop closes the receiver, so there is no one left to deliver to (see
251 /// [`StreamTerminationReason`] for the deliverable-only rationale).
252 StreamTerminated(StreamTerminationReason),
253}
254
255impl<ExchangeKey, AssetKey, InstrumentKey> AccountEvent<ExchangeKey, AssetKey, InstrumentKey>
256where
257 AssetKey: Eq,
258 InstrumentKey: Eq,
259{
260 pub fn snapshot(self) -> Option<AccountSnapshot<ExchangeKey, AssetKey, InstrumentKey>> {
261 match self.kind {
262 AccountEventKind::Snapshot(snapshot) => Some(snapshot),
263 _ => None,
264 }
265 }
266}
267
268#[derive(
269 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
270)]
271pub struct AccountSnapshot<
272 ExchangeKey = ExchangeIndex,
273 AssetKey = AssetIndex,
274 InstrumentKey = InstrumentIndex,
275> {
276 pub exchange: ExchangeKey,
277 pub balances: Vec<AssetBalance<AssetKey>>,
278 pub instruments: Vec<InstrumentAccountSnapshot<ExchangeKey, AssetKey, InstrumentKey>>,
279}
280
281/// serde `default` for an `Option` field whose inner type is generic — returns `None` without
282/// requiring the inner type to be `Default` (see the `isolated` field below).
283fn none_option<T>() -> Option<T> {
284 None
285}
286
287/// One instrument's slice of an [`AccountSnapshot`]: its orders, and its position or isolated
288/// balances where the venue has them.
289#[derive(
290 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
291)]
292pub struct InstrumentAccountSnapshot<
293 ExchangeKey = ExchangeIndex,
294 AssetKey = AssetIndex,
295 InstrumentKey = InstrumentIndex,
296> {
297 pub instrument: InstrumentKey,
298 /// Orders the venue reported for this instrument, keyed by the `ClientOrderId` the order was
299 /// placed with where the venue reports it.
300 ///
301 /// Whether this is **every** order open at the venue is stated by
302 /// [`orders_complete`](Self::orders_complete), not implied by the list.
303 #[serde(default = "Vec::new")]
304 pub orders: Vec<OrderSnapshot<ExchangeKey, AssetKey, InstrumentKey>>,
305 /// `true` only when [`orders`](Self::orders) lists every order open at the venue for this
306 /// instrument when it was read, each under the `ClientOrderId` it was placed with.
307 ///
308 /// A consumer may then read an order's **absence** as "no longer open": it filled, was
309 /// cancelled or expired. The engine does exactly that, retiring a tracked order a complete
310 /// list omits. A wrong `true` therefore retires live orders, so a client sets it only when
311 /// nothing can be missing:
312 /// - the read covered every open order, with no page cap or partial result;
313 /// - no open order was dropped on conversion;
314 /// - each order carries the id it was placed with, so a tracked order can be found in it.
315 ///
316 /// `false` claims nothing about absent orders, and is the default for a snapshot that does not
317 /// say.
318 #[serde(default)]
319 pub orders_complete: bool,
320 /// The venue's position in this instrument, for any instrument whose holding the venue
321 /// tracks separately from cash balances: perpetuals, futures, options, and equities (e.g.
322 /// Alpaca).
323 ///
324 /// [`PositionReport::Unreported`] when the venue does not report positions, for holdings
325 /// reported as asset balances instead, such as spot crypto, and when the read could not
326 /// establish the position. [`PositionReport::Flat`] is a claim that the venue holds none, so
327 /// a client reports it only when it read every position the venue has for the instrument. See
328 /// each client's `account_snapshot` for what it reports.
329 ///
330 /// A client that reports positions lists each instrument it was asked for whose position it
331 /// can establish, flat ones included. An instrument it leaves out, like one it reports as
332 /// `Unreported`, is unknown, never flat: a client can be unable to establish a position (see
333 /// its `account_snapshot`). Asked for all instruments (an empty list), it may list only those
334 /// with something to report.
335 #[serde(default, skip_serializing_if = "PositionReport::is_unreported")]
336 pub position: PositionReport,
337 /// Per-pair isolated-margin balances and risk, for venues with isolated sub-accounts
338 /// (e.g. Binance isolated margin). `None` for cross margin, spot, and all other contexts.
339 ///
340 /// Surfaced here — attached to the instrument — rather than folded into the asset-keyed
341 /// [`AccountSnapshot::balances`] because isolated sub-accounts are per-`(pair, asset)`: the same
342 /// asset (e.g. `USDT`) in two isolated pairs is a separate pool, which the asset-keyed balance
343 /// model cannot represent without collision. The engine does not store this; a consumer reads
344 /// it off the snapshot to compose per-pair risk. See [`IsolatedInstrumentState`].
345 ///
346 // `default = "none_option"` (not a bare `#[serde(default)]`) avoids serde inferring a spurious
347 // `AssetKey: Default` bound: a bare default on a generic-typed field conservatively requires the
348 // field type to be `Default` (the `position` field escapes this only because `PositionReport` is
349 // concrete). Naming a function makes serde *call* it instead, requiring only `AssetKey: Deserialize`.
350 #[serde(default = "none_option", skip_serializing_if = "Option::is_none")]
351 pub isolated: Option<IsolatedInstrumentState<AssetKey>>,
352}
353
354/// Per-pair isolated-margin state attached to an [`InstrumentAccountSnapshot`].
355///
356/// Binance isolated margin (and other CEX isolated/per-pair sub-accounts) hold balances
357/// per-`(pair, asset)`, not per-asset, so they are surfaced attached to the instrument rather than
358/// in the asset-keyed [`AccountSnapshot::balances`]. A consumer composes per-pair risk from `base`,
359/// `quote`, and `risk`.
360#[derive(
361 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
362)]
363pub struct IsolatedInstrumentState<AssetKey = AssetIndex> {
364 /// Base-asset balance of the pair's isolated sub-account (carries per-asset debt).
365 pub base: AssetBalance<AssetKey>,
366 /// Quote-asset balance of the pair's isolated sub-account (carries per-asset debt).
367 pub quote: AssetBalance<AssetKey>,
368 /// Per-pair risk metrics — snapshot-fresh, not live (see [`IsolatedMarginRisk`]).
369 pub risk: IsolatedMarginRisk,
370}
371
372/// Per-pair isolated-margin risk metrics, surfaced on [`IsolatedInstrumentState`].
373///
374/// Every field is `Option` — a venue may omit any given metric, and a missing metric must not
375/// drop the surrounding balance snapshot.
376///
377/// # Freshness
378/// These are **snapshot-only**: authoritative as of the last `account_snapshot` and refreshed on
379/// snapshot. Unlike balances, there is **no live-stream twin** — the WS `outboundAccountPosition`
380/// frame carries no margin-level / liquidation data. The live signal for risk crossing a threshold
381/// is the venue's `marginLevelStatusChange` event (surfaced observably, not accumulated here).
382#[derive(
383 Debug,
384 Copy,
385 Clone,
386 PartialEq,
387 Eq,
388 PartialOrd,
389 Ord,
390 Hash,
391 Default,
392 Deserialize,
393 Serialize,
394 Constructor,
395)]
396pub struct IsolatedMarginRisk {
397 /// Margin level of the isolated pair (collateral-to-debt ratio); higher is safer.
398 pub margin_level: Option<Decimal>,
399 /// Margin ratio of the isolated pair.
400 pub margin_ratio: Option<Decimal>,
401 /// Estimated liquidation price for the isolated pair.
402 pub liquidation_price: Option<Decimal>,
403}
404
405/// Live per-instrument isolated-margin balance update payload (the
406/// [`AccountEventKind::InstrumentBalanceUpdate`] counterpart of [`IsolatedInstrumentState`]).
407///
408/// Carries a point-in-time `free`/`locked` **snapshot** for the pair's `base` and `quote` assets —
409/// NOT a delta — keyed by instrument. Structurally analogous to [`AssetBalanceUpdate`] but
410/// per-instrument: it carries no debt, so applying it keeps `free`/`locked` live while preserving
411/// any known per-asset debt (use [`Balance::apply_stream_update`](balance::Balance::apply_stream_update)).
412#[derive(
413 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
414)]
415pub struct InstrumentBalanceUpdate<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
416 /// Instrument (isolated pair) the update applies to.
417 pub instrument: InstrumentKey,
418 /// Base-asset `free`/`locked` update for the pair's isolated sub-account.
419 pub base: AssetBalanceUpdate<AssetKey>,
420 /// Quote-asset `free`/`locked` update for the pair's isolated sub-account.
421 pub quote: AssetBalanceUpdate<AssetKey>,
422}
423
424impl<ExchangeKey, AssetKey, InstrumentKey> AccountSnapshot<ExchangeKey, AssetKey, InstrumentKey> {
425 pub fn time_most_recent(&self) -> Option<DateTime<Utc>> {
426 let order_times = self.instruments.iter().flat_map(|instrument| {
427 instrument
428 .orders
429 .iter()
430 .filter_map(|order| order.state.time_exchange())
431 });
432 let balance_times = self.balances.iter().map(|balance| balance.time_exchange);
433
434 order_times.chain(balance_times).max()
435 }
436
437 pub fn assets(&self) -> impl Iterator<Item = &AssetKey> {
438 self.balances.iter().map(|balance| &balance.asset)
439 }
440
441 pub fn instruments(&self) -> impl Iterator<Item = &InstrumentKey> {
442 self.instruments.iter().map(|snapshot| &snapshot.instrument)
443 }
444}
445
446// Both tests exercise the feature-gated `emit_stream_terminated`, so this module compiles under the
447// same venue features — no feature → no function → no test for it.
448#[cfg(all(
449 test,
450 any(
451 feature = "alpaca",
452 feature = "binance",
453 feature = "ibkr",
454 feature = "hyperliquid"
455 )
456))]
457#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
458mod tests {
459 use super::*;
460
461 /// The shared terminal-emit helper every venue connector funnels through must deliver an
462 /// in-band [`AccountEventKind::StreamTerminated`] carrying the given exchange and reason.
463 #[test]
464 fn emit_stream_terminated_delivers_event_in_band() {
465 let (tx, mut rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
466 let reason = StreamTerminationReason::ReconnectBudgetExhausted {
467 attempts: 7,
468 last_error: "socket reset".to_string(),
469 };
470
471 emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason.clone());
472
473 let event = rx.try_recv().expect("expected a terminal event");
474 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
475 assert!(
476 matches!(event.kind, AccountEventKind::StreamTerminated(r) if r == reason),
477 "expected StreamTerminated carrying the supplied reason",
478 );
479 }
480
481 /// Per the D6 consumer-drop decision, emitting after the consumer dropped the receiver is a
482 /// best-effort silent no-op — it must not panic (there is no one left to deliver to).
483 #[test]
484 fn emit_stream_terminated_is_noop_when_receiver_dropped() {
485 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
486 drop(rx);
487
488 // Must not panic even though the send is dropped on the floor.
489 emit_stream_terminated(
490 &tx,
491 ExchangeId::HyperliquidPerp,
492 StreamTerminationReason::Error("stream closed".to_string()),
493 );
494 }
495}