Skip to main content

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