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