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// Clippy 1.99 checks each field's span rather than the whole expression's, so the
24// `Self { field: field }` that `derive_more::Constructor` expands to is reported at our own
25// field declarations. Remove once rust-lang/rust-clippy#17525 is fixed in a stable release.
26#![allow(clippy::redundant_field_names)]
27
28//! # Barter-Execution
29//! Stream private account data from financial venues, and execute (live or mock) orders. Also provides
30//! a feature rich MockExchange and MockExecutionClient to assist with backtesting and paper-trading.
31//!
32//! **It is:**
33//! * **Easy**: ExecutionClient trait provides a unified and simple language for interacting with exchanges.
34//! * **Normalised**: Allow your strategy to communicate with every real or MockExchange using the same interface.
35//! * **Extensible**: Barter-Execution is highly extensible, making it easy to contribute by adding new exchange integrations!
36//!
37//! See `README.md` for more information and examples.
38
39// Silence unused_crate_dependencies for dev-dependencies used only in tests
40#[cfg(test)]
41use serial_test as _;
42#[cfg(test)]
43use temp_env as _;
44#[cfg(test)]
45use tokio_tungstenite as _;
46#[cfg(test)]
47use tracing_subscriber as _;
48#[cfg(test)]
49use wiremock as _;
50
51use crate::{
52 balance::{AssetBalance, AssetBalanceUpdate},
53 error::StreamTerminationReason,
54 order::{Order, OrderSnapshot, request::OrderResponseCancel},
55 position::PositionReport,
56 trade::Trade,
57};
58use chrono::{DateTime, Utc};
59use derive_more::{Constructor, From};
60use order::state::OrderState;
61use rust_decimal::Decimal;
62use rustrade_instrument::{
63 asset::{AssetIndex, name::AssetNameExchange},
64 exchange::{ExchangeId, ExchangeIndex},
65 instrument::{InstrumentIndex, name::InstrumentNameExchange},
66};
67use rustrade_integration::collection::snapshot::Snapshot;
68use serde::{Deserialize, Serialize};
69// Only `emit_stream_terminated` (and its tests) name `mpsc`, both gated to the venue features —
70// gate the import the same way so the default-empty feature set does not warn it as unused.
71#[cfg(any(
72 feature = "alpaca",
73 feature = "binance",
74 feature = "ibkr",
75 feature = "hyperliquid"
76))]
77use tokio::sync::mpsc;
78
79pub mod balance;
80pub mod borrow_capacity;
81pub use borrow_capacity::BorrowCapacity;
82pub mod cash_flow;
83pub use cash_flow::{CashFlow, CashFlowId, CashFlowKind, CashFlowsRead};
84pub mod client;
85pub mod error;
86pub mod exchange;
87pub mod fee;
88pub use fee::{
89 FeeModel, FeeModelConfig, Liquidity, PerContractFeeModel, PercentageFeeModel, SpotFeeAsset,
90 ZeroFeeModel,
91};
92pub mod fill;
93pub use fill::{
94 BidAskFillModel, FillContext, FillModel, LastPriceFillModel, MidpointFillModel, SimFillConfig,
95};
96pub mod fill_recovery;
97pub use fill_recovery::{FillRecoveryFailure, FillRecoveryGap, FillRecoveryScope};
98pub mod holding_cost;
99pub use holding_cost::{
100 AccrualDays, BorrowFeeModel, DailyAccrual, DayCount, FinancingModel, FinancingRates,
101 FundingModel, HeldPosition, HoldingCharge, HoldingCostModel, HolidayCalendar, RateSeries,
102 RateSeriesUnsorted, SettlementCalendar, WeekendsOnly,
103};
104pub mod indexer;
105pub mod map;
106pub mod market;
107pub use market::{MarketDepth, MarketSnapshot};
108pub mod notice;
109pub use notice::{AccountNotice, NoticeKind};
110pub mod order;
111pub mod position;
112pub mod shortability;
113pub mod slippage;
114pub use shortability::{Shortability, ShortabilityProvider, ShortabilityTable};
115pub use slippage::{SlippageConfig, SlippageConfigError};
116pub mod trade;
117
118/// Convenient type alias for an [`AccountEvent`] keyed with [`ExchangeId`],
119/// [`AssetNameExchange`], and [`InstrumentNameExchange`].
120pub type UnindexedAccountEvent =
121 AccountEvent<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
122
123/// Convenient type alias for an [`AccountSnapshot`] keyed with [`ExchangeId`],
124/// [`AssetNameExchange`], and [`InstrumentNameExchange`].
125pub type UnindexedAccountSnapshot =
126 AccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
127
128#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
129pub struct AccountEvent<
130 ExchangeKey = ExchangeIndex,
131 AssetKey = AssetIndex,
132 InstrumentKey = InstrumentIndex,
133> {
134 pub exchange: ExchangeKey,
135 pub kind: AccountEventKind<ExchangeKey, AssetKey, InstrumentKey>,
136}
137
138impl<ExchangeKey, AssetKey, InstrumentKey> AccountEvent<ExchangeKey, AssetKey, InstrumentKey> {
139 pub fn new<K>(exchange: ExchangeKey, kind: K) -> Self
140 where
141 K: Into<AccountEventKind<ExchangeKey, AssetKey, InstrumentKey>>,
142 {
143 Self {
144 exchange,
145 kind: kind.into(),
146 }
147 }
148
149 /// Named constructor for the terminal [`StreamTerminated`](AccountEventKind::StreamTerminated)
150 /// event carrying the given [`StreamTerminationReason`].
151 pub(crate) fn stream_terminated(
152 exchange: ExchangeKey,
153 reason: StreamTerminationReason,
154 ) -> Self {
155 Self {
156 exchange,
157 kind: AccountEventKind::StreamTerminated(reason),
158 }
159 }
160}
161
162/// Best-effort in-band emit of a terminal
163/// [`StreamTerminated`](AccountEventKind::StreamTerminated) on the account-event channel, sent just
164/// before a venue's stream task exits so stream death is delivered in-band rather than inferred from
165/// channel EOF.
166///
167/// Shared by the channel-based venue connectors (Binance spot/margin, Alpaca, IBKR, Hyperliquid)
168/// so the single send of the terminal event lives in one place, at the abstraction level where
169/// [`AccountEvent`] is defined rather than inside any one vendor module. (The Mock client yields
170/// into a `Stream` rather than an mpsc channel, so it builds the terminal event via
171/// `UnindexedAccountEvent::stream_terminated` directly and does not funnel through this helper.)
172/// The send is best-effort: if the consumer already dropped the receiver it is a silent no-op —
173/// which is the consumer-initiated-drop case, deliberately *not* signalled (there is no one left
174/// to deliver to).
175//
176// Gated to the venues that send terminal events through an mpsc channel from a spawned stream task,
177// so the crate's default-empty feature set does not warn this as unused (mirrors `parse_env_bool`
178// below). The Mock client is always compiled but is excluded by design — see the note above.
179#[cfg(any(
180 feature = "alpaca",
181 feature = "binance",
182 feature = "ibkr",
183 feature = "hyperliquid"
184))]
185pub(crate) fn emit_stream_terminated(
186 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
187 exchange: ExchangeId,
188 reason: StreamTerminationReason,
189) {
190 let _ = tx.send(UnindexedAccountEvent::stream_terminated(exchange, reason));
191}
192
193/// Parse a boolean environment-variable value using the single cross-venue policy.
194///
195/// Accepts only `"true"` / `"false"` (case-insensitive, surrounding whitespace trimmed); every other
196/// value returns `None` so the caller can surface an explicit "must be true or false" error rather
197/// than silently coercing. Deliberately does **not** accept `"1"`/`"0"` or other truthy spellings —
198/// one strict, unambiguous spelling shared by every venue's `from_env` (Alpaca `ALPACA_PAPER`,
199/// Binance `BINANCE_TESTNET`, Hyperliquid `HYPERLIQUID_TESTNET`) keeps the network-selection toggle
200/// impossible to misread.
201//
202// Gated to the venues that read env-bool toggles so the crate's default-empty feature set does not
203// warn this as unused.
204#[cfg(any(feature = "alpaca", feature = "binance", feature = "hyperliquid"))]
205pub(crate) fn parse_env_bool(value: &str) -> Option<bool> {
206 let trimmed = value.trim();
207 if trimmed.eq_ignore_ascii_case("true") {
208 Some(true)
209 } else if trimmed.eq_ignore_ascii_case("false") {
210 Some(false)
211 } else {
212 None
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, From)]
217#[non_exhaustive]
218pub enum AccountEventKind<ExchangeKey, AssetKey, InstrumentKey> {
219 /// Full [`AccountSnapshot`] - replaces all existing state.
220 Snapshot(AccountSnapshot<ExchangeKey, AssetKey, InstrumentKey>),
221
222 /// Single [`AssetBalance`] snapshot - replaces existing balance state.
223 ///
224 /// Sourced from a REST account snapshot: carries the **full** balance including any per-asset
225 /// margin debt (`borrowed`/`interest`). This is the authoritative source for debt totals.
226 BalanceSnapshot(Snapshot<AssetBalance<AssetKey>>),
227
228 /// Single [`AssetBalanceUpdate`] - applies a WS partial (`free`/`locked` only).
229 ///
230 /// Sourced from an exchange WS user-data stream (e.g. Binance `outboundAccountPosition`). It
231 /// carries **no** margin debt, so applying it updates `free`/`locked` while **preserving** any
232 /// existing [`MarginDetails`](balance::MarginDetails) — debt cannot be silently clobbered by a
233 /// stream update. Debt totals remain as fresh as the last [`BalanceSnapshot`](Self::BalanceSnapshot).
234 BalanceStreamUpdate(Snapshot<AssetBalanceUpdate<AssetKey>>),
235
236 /// Live per-instrument isolated-margin balance update (`free`/`locked` of one asset of a pair).
237 ///
238 /// The stream counterpart of [`InstrumentAccountSnapshot::isolated`] for venues with per-pair
239 /// isolated sub-accounts (e.g. Binance isolated margin). Carries a point-in-time `free`/`locked`
240 /// **snapshot** of one of the pair's assets — NOT a delta — keyed by instrument as well as
241 /// asset, because isolated balances are per-`(pair, asset)` and cannot be folded into the
242 /// asset-keyed balance state without collision (see [`InstrumentBalanceUpdate`]).
243 ///
244 /// The engine deliberately does **not** store this (the per-asset balance state is informational
245 /// only and the engine never reads it for sizing/gating); a consumer reads it off the account
246 /// event feed. Debt totals stay as fresh as the last
247 /// [`BalanceSnapshot`](Self::BalanceSnapshot) per the debt-freshness contract.
248 InstrumentBalanceUpdate(InstrumentBalanceUpdate<AssetKey, InstrumentKey>),
249
250 /// Single [`Order`] snapshot - used to upsert existing order state if it's more recent.
251 ///
252 /// This variant covers general order updates, and open order responses.
253 OrderSnapshot(Snapshot<Order<ExchangeKey, InstrumentKey, OrderState<AssetKey, InstrumentKey>>>),
254
255 /// Response to an [`OrderRequestCancel<ExchangeKey, InstrumentKey>`](order::request::OrderRequestOpen).
256 OrderCancelled(OrderResponseCancel<ExchangeKey, AssetKey, InstrumentKey>),
257
258 /// [`Order<ExchangeKey, InstrumentKey, Open>`] partial or full-fill.
259 ///
260 /// The fee asset (`AssetKey`) may be the quote asset, base asset, or a third-party
261 /// asset (e.g., BNB on Binance). Use `fees.fees_quote` for quote-equivalent value
262 /// when available.
263 Trade(Trade<AssetKey, InstrumentKey>),
264
265 /// The account event stream has ended; no further events will arrive on it.
266 ///
267 /// This is the in-band, programmatic signal that a stream died — delivered on the **same**
268 /// account feed as every other event rather than being inferred from channel EOF or read from
269 /// logs. The [`StreamTerminationReason`] distinguishes a venue that exhausted its reconnect
270 /// budget from an unrecoverable error, so the consumer can apply its own recovery policy
271 /// (re-establish the stream, re-sync via REST, halt trading). The library reports *that* and
272 /// *why* the stream ended; it does not prescribe the response.
273 ///
274 /// Only terminations deliverable while the consumer is still listening are signalled: a
275 /// consumer-initiated drop closes the receiver, so there is no one left to deliver to (see
276 /// [`StreamTerminationReason`] for the deliverable-only rationale).
277 StreamTerminated(StreamTerminationReason),
278
279 /// Fill recovery gave up on a span of fills: fills in it may not have been delivered.
280 ///
281 /// Sent by venues that recover missed fills after a reconnect (Binance Spot and Margin,
282 /// Alpaca) when a recovery read fails for good, once per failed read. The stream carries on.
283 /// The engine logs it and changes no state; reconciling is the consumer's policy. See
284 /// [`FillRecoveryGap`] for what the span covers and how to read it again.
285 FillRecoveryGaveUp(FillRecoveryGap<InstrumentKey>),
286
287 /// A trade delivered earlier was busted or corrected by the venue.
288 ///
289 /// The earlier [`Trade`](Self::Trade) is not withdrawn, so state built on it is wrong until
290 /// the consumer applies this. The engine logs it and changes no state; reversing the trade is
291 /// the consumer's policy. See [`TradeAmendment`](trade::TradeAmendment).
292 TradeAmended(trade::TradeAmendment<AssetKey, InstrumentKey>),
293
294 /// An attempt to re-initialise the account stream after it ended failed. Attempts continue
295 /// with backoff.
296 ///
297 /// Sent by a consumer that re-initialises the account stream itself, such as the `rustrade`
298 /// crate's `ExecutionManager`, once per failed attempt and in order with the other account
299 /// events. It reports that the account link is still down, so it is not evidence the link is
300 /// up. The engine logs it and changes no state. How long to keep waiting, and whether to halt
301 /// or alert, is the consumer's policy. See [`AccountReinitFailure`](error::AccountReinitFailure).
302 ReinitFailed(error::AccountReinitFailure<AssetKey, InstrumentKey>),
303
304 /// Cash moved into or out of the account other than by a trade: funding, interest, a borrow
305 /// fee or a rebate, as the venue posted it.
306 ///
307 /// A flow is a delta, signed positive when the account received it. The engine adds a flow
308 /// attributed to an instrument to the `carry` of that instrument's open position (see
309 /// `Position::carry` in `rustrade`), and applies none to balances: the venue's balances already
310 /// include what it has posted. See [`CashFlow`] for which venues send it.
311 CashFlow(cash_flow::CashFlow<AssetKey, InstrumentKey>),
312
313 /// The venue reported a change in the account's margin or liquidation state: a margin call, a
314 /// liquidation warning, a liquidation under way, or that one of those no longer holds.
315 ///
316 /// A notice changes no balance or position. The engine logs it, at `warn` when
317 /// [`NoticeKind::is_escalation`] and at `info` otherwise, and changes no state; what to do
318 /// about it is the consumer's policy. A notice sent while the stream was disconnected is not
319 /// recovered. See [`AccountNotice`] for which venues send it.
320 Notice(notice::AccountNotice<InstrumentKey>),
321}
322
323impl<ExchangeKey, AssetKey, InstrumentKey> AccountEvent<ExchangeKey, AssetKey, InstrumentKey>
324where
325 AssetKey: Eq,
326 InstrumentKey: Eq,
327{
328 pub fn snapshot(self) -> Option<AccountSnapshot<ExchangeKey, AssetKey, InstrumentKey>> {
329 match self.kind {
330 AccountEventKind::Snapshot(snapshot) => Some(snapshot),
331 _ => None,
332 }
333 }
334}
335
336#[derive(
337 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
338)]
339pub struct AccountSnapshot<
340 ExchangeKey = ExchangeIndex,
341 AssetKey = AssetIndex,
342 InstrumentKey = InstrumentIndex,
343> {
344 pub exchange: ExchangeKey,
345 pub balances: Vec<AssetBalance<AssetKey>>,
346 pub instruments: Vec<InstrumentAccountSnapshot<ExchangeKey, AssetKey, InstrumentKey>>,
347}
348
349/// serde `default` for an `Option` field whose inner type is generic — returns `None` without
350/// requiring the inner type to be `Default` (see the `isolated` field below).
351fn none_option<T>() -> Option<T> {
352 None
353}
354
355/// One instrument's slice of an [`AccountSnapshot`]: its orders, and its position or isolated
356/// balances where the venue has them.
357#[derive(
358 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
359)]
360pub struct InstrumentAccountSnapshot<
361 ExchangeKey = ExchangeIndex,
362 AssetKey = AssetIndex,
363 InstrumentKey = InstrumentIndex,
364> {
365 pub instrument: InstrumentKey,
366 /// Orders the venue reported for this instrument, keyed by the `ClientOrderId` the order was
367 /// placed with where the venue reports it.
368 ///
369 /// Whether this is **every** order open at the venue is stated by
370 /// [`orders_complete`](Self::orders_complete), not implied by the list.
371 #[serde(default = "Vec::new")]
372 pub orders: Vec<OrderSnapshot<ExchangeKey, AssetKey, InstrumentKey>>,
373 /// `true` only when [`orders`](Self::orders) lists every order open at the venue for this
374 /// instrument when it was read, each under the `ClientOrderId` it was placed with.
375 ///
376 /// A consumer may then read an order's **absence** as "no longer open": it filled, was
377 /// cancelled or expired. The engine does exactly that, retiring a tracked order a complete
378 /// list omits. A wrong `true` therefore retires live orders, so a client sets it only when
379 /// nothing can be missing:
380 /// - the read covered every open order, with no page cap or partial result;
381 /// - no open order was dropped on conversion;
382 /// - each order carries the id it was placed with, so a tracked order can be found in it.
383 ///
384 /// `false` claims nothing about absent orders, and is the default for a snapshot that does not
385 /// say.
386 #[serde(default)]
387 pub orders_complete: bool,
388 /// The venue's position in this instrument, for any instrument whose holding the venue
389 /// tracks separately from cash balances: perpetuals, futures, options, and equities (e.g.
390 /// Alpaca).
391 ///
392 /// [`PositionReport::Unreported`] when the venue does not report positions, for holdings
393 /// reported as asset balances instead, such as spot crypto, and when the read could not
394 /// establish the position. [`PositionReport::Flat`] is a claim that the venue holds none, so
395 /// a client reports it only when it read every position the venue has for the instrument. See
396 /// each client's `account_snapshot` for what it reports.
397 ///
398 /// A client that reports positions lists each instrument it was asked for whose position it
399 /// can establish, flat ones included. An instrument it leaves out, like one it reports as
400 /// `Unreported`, is unknown, never flat: a client can be unable to establish a position (see
401 /// its `account_snapshot`). Asked for all instruments (an empty list), it may list only those
402 /// with something to report.
403 #[serde(default, skip_serializing_if = "PositionReport::is_unreported")]
404 pub position: PositionReport,
405 /// Per-pair isolated-margin balances and risk, for venues with isolated sub-accounts
406 /// (e.g. Binance isolated margin). `None` for cross margin, spot, and all other contexts.
407 ///
408 /// Surfaced here — attached to the instrument — rather than folded into the asset-keyed
409 /// [`AccountSnapshot::balances`] because isolated sub-accounts are per-`(pair, asset)`: the same
410 /// asset (e.g. `USDT`) in two isolated pairs is a separate pool, which the asset-keyed balance
411 /// model cannot represent without collision. The engine does not store this; a consumer reads
412 /// it off the snapshot to compose per-pair risk. See [`IsolatedInstrumentState`].
413 ///
414 // `default = "none_option"` (not a bare `#[serde(default)]`) avoids serde inferring a spurious
415 // `AssetKey: Default` bound: a bare default on a generic-typed field conservatively requires the
416 // field type to be `Default` (the `position` field escapes this only because `PositionReport` is
417 // concrete). Naming a function makes serde *call* it instead, requiring only `AssetKey: Deserialize`.
418 #[serde(default = "none_option", skip_serializing_if = "Option::is_none")]
419 pub isolated: Option<IsolatedInstrumentState<AssetKey>>,
420}
421
422/// Per-pair isolated-margin state attached to an [`InstrumentAccountSnapshot`].
423///
424/// Binance isolated margin (and other CEX isolated/per-pair sub-accounts) hold balances
425/// per-`(pair, asset)`, not per-asset, so they are surfaced attached to the instrument rather than
426/// in the asset-keyed [`AccountSnapshot::balances`]. A consumer composes per-pair risk from `base`,
427/// `quote`, and `risk`.
428#[derive(
429 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
430)]
431pub struct IsolatedInstrumentState<AssetKey = AssetIndex> {
432 /// Base-asset balance of the pair's isolated sub-account (carries per-asset debt).
433 pub base: AssetBalance<AssetKey>,
434 /// Quote-asset balance of the pair's isolated sub-account (carries per-asset debt).
435 pub quote: AssetBalance<AssetKey>,
436 /// Per-pair risk metrics — snapshot-fresh, not live (see [`IsolatedMarginRisk`]).
437 pub risk: IsolatedMarginRisk,
438}
439
440/// Per-pair isolated-margin risk metrics, surfaced on [`IsolatedInstrumentState`].
441///
442/// Every field is `Option` — a venue may omit any given metric, and a missing metric must not
443/// drop the surrounding balance snapshot.
444///
445/// # Freshness
446/// These are **snapshot-only**: authoritative as of the last `account_snapshot` and refreshed on
447/// snapshot. Unlike balances, there is **no live-stream twin** — the WS `outboundAccountPosition`
448/// frame carries no margin-level / liquidation data. The live signal for risk crossing a threshold
449/// is the venue's `MARGIN_LEVEL_STATUS_CHANGE` event (surfaced observably, not accumulated here).
450#[derive(
451 Debug,
452 Copy,
453 Clone,
454 PartialEq,
455 Eq,
456 PartialOrd,
457 Ord,
458 Hash,
459 Default,
460 Deserialize,
461 Serialize,
462 Constructor,
463)]
464pub struct IsolatedMarginRisk {
465 /// Margin level of the isolated pair (collateral-to-debt ratio); higher is safer.
466 pub margin_level: Option<Decimal>,
467 /// Margin ratio of the isolated pair.
468 pub margin_ratio: Option<Decimal>,
469 /// Estimated liquidation price for the isolated pair.
470 pub liquidation_price: Option<Decimal>,
471}
472
473/// Live per-instrument isolated-margin balance update payload (the
474/// [`AccountEventKind::InstrumentBalanceUpdate`] counterpart of [`IsolatedInstrumentState`]).
475///
476/// Carries a point-in-time `free`/`locked` **snapshot** of one asset (the pair's base or its
477/// quote) in the pair's isolated sub-account — NOT a delta. It is the per-instrument counterpart of
478/// [`AccountEventKind::BalanceStreamUpdate`]: one event per asset, so a venue frame that lists only
479/// the asset that changed yields one event, and a frame listing both yields two. Apply each one
480/// to the `(instrument, asset)` it names and leave the pair's other asset as it was. Which side
481/// it is follows from comparing [`AssetBalanceUpdate::asset`] with the instrument's base and
482/// quote. The two events from one frame share a `time_exchange` but arrive separately, so a
483/// consumer can see one side updated before the other.
484///
485/// It carries no debt, so applying it keeps `free`/`locked` live while preserving any known
486/// per-asset debt (use [`Balance::apply_stream_update`](balance::Balance::apply_stream_update)).
487#[derive(
488 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
489)]
490pub struct InstrumentBalanceUpdate<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
491 /// Instrument (isolated pair) the update applies to.
492 pub instrument: InstrumentKey,
493 /// `free`/`locked` of one of the pair's assets in its isolated sub-account.
494 pub balance: AssetBalanceUpdate<AssetKey>,
495}
496
497impl<ExchangeKey, AssetKey, InstrumentKey> AccountSnapshot<ExchangeKey, AssetKey, InstrumentKey> {
498 pub fn time_most_recent(&self) -> Option<DateTime<Utc>> {
499 let order_times = self.instruments.iter().flat_map(|instrument| {
500 instrument
501 .orders
502 .iter()
503 .filter_map(|order| order.state.time_exchange())
504 });
505 let balance_times = self.balances.iter().map(|balance| balance.time_exchange);
506
507 order_times.chain(balance_times).max()
508 }
509
510 pub fn assets(&self) -> impl Iterator<Item = &AssetKey> {
511 self.balances.iter().map(|balance| &balance.asset)
512 }
513
514 pub fn instruments(&self) -> impl Iterator<Item = &InstrumentKey> {
515 self.instruments.iter().map(|snapshot| &snapshot.instrument)
516 }
517}
518
519// Both tests exercise the feature-gated `emit_stream_terminated`, so this module compiles under the
520// same venue features — no feature → no function → no test for it.
521#[cfg(all(
522 test,
523 any(
524 feature = "alpaca",
525 feature = "binance",
526 feature = "ibkr",
527 feature = "hyperliquid"
528 )
529))]
530#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
531mod tests {
532 use super::*;
533
534 /// The shared terminal-emit helper every venue connector funnels through must deliver an
535 /// in-band [`AccountEventKind::StreamTerminated`] carrying the given exchange and reason.
536 #[test]
537 fn emit_stream_terminated_delivers_event_in_band() {
538 let (tx, mut rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
539 let reason = StreamTerminationReason::ReconnectBudgetExhausted {
540 attempts: 7,
541 last_error: "socket reset".to_string(),
542 };
543
544 emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason.clone());
545
546 let event = rx.try_recv().expect("expected a terminal event");
547 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
548 assert!(
549 matches!(event.kind, AccountEventKind::StreamTerminated(r) if r == reason),
550 "expected StreamTerminated carrying the supplied reason",
551 );
552 }
553
554 /// A failed re-init survives a serde round trip, so audit and replay keep it, error and all.
555 #[test]
556 fn a_reinit_failure_round_trips_through_serde() {
557 use crate::error::{
558 AccountReinitFailure, AccountStreamInitError, ClientError, ConnectivityError,
559 };
560
561 let event = AccountEvent::new(
562 ExchangeIndex::new(0),
563 AccountEventKind::ReinitFailed(AccountReinitFailure::new(
564 3,
565 AccountStreamInitError::Client(ClientError::Connectivity(
566 ConnectivityError::Timeout,
567 )),
568 )),
569 );
570
571 let json = serde_json::to_string(&event).unwrap();
572 let decoded: AccountEvent = serde_json::from_str(&json).unwrap();
573
574 assert_eq!(decoded, event);
575 }
576
577 /// Per the D6 consumer-drop decision, emitting after the consumer dropped the receiver is a
578 /// best-effort silent no-op — it must not panic (there is no one left to deliver to).
579 #[test]
580 fn emit_stream_terminated_is_noop_when_receiver_dropped() {
581 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
582 drop(rx);
583
584 // Must not panic even though the send is dropped on the floor.
585 emit_stream_terminated(
586 &tx,
587 ExchangeId::HyperliquidPerp,
588 StreamTerminationReason::Error("stream closed".to_string()),
589 );
590 }
591}