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