Skip to main content

rustrade_execution/
balance.rs

1use chrono::{DateTime, Utc};
2use derive_more::Constructor;
3use rust_decimal::Decimal;
4use serde::{Deserialize, Serialize};
5
6#[derive(
7    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
8)]
9pub struct AssetBalance<AssetKey> {
10    pub asset: AssetKey,
11    pub balance: Balance,
12    /// A time no later than when the venue's account held this balance.
13    ///
14    /// Balance state is ordered by it: a balance replaces one held only if it is at least as
15    /// recent, and a stream update older than the balance held is dropped. So it errs early,
16    /// never late. A balance read over REST is stamped no later than the moment its request was
17    /// sent, less how far the venue's REST view can trail its stream (see
18    /// [`ExecutionClient::fetch_balances`](crate::client::ExecutionClient::fetch_balances)): its
19    /// state can be older than the read, never newer. Erring early costs little: a stream event
20    /// from between the stamp and the read that arrives after the balance applies over it, and
21    /// restates what the balance already held, or older holdings until the next event.
22    pub time_exchange: DateTime<Utc>,
23}
24
25/// Per-asset margin debt detail for venues that report borrowing on a per-asset basis
26/// (the CEX per-asset-margin class — Binance / Kraken / Bitfinex / dYdX).
27///
28/// `borrowed` and `interest` are always co-reported by such venues' REST snapshots, so they
29/// are modelled as a single `Option`-of-struct on [`Balance`] rather than two loose `Option`s —
30/// this keeps them co-present and preserves `Balance`'s `Copy`/`Eq`/`Ord`/`Hash`/`Default`.
31///
32/// Account-level-margin venues (e.g. IBKR, which reports only aggregate `EquityWithLoanValue` /
33/// `MaintMarginReq`, never per-asset `borrowed`/`interest`) legitimately leave [`Balance::margin`]
34/// as `None`.
35///
36/// Futures/perps *position* margin (maintenance margin, unrealised PnL) is a different model and
37/// is intentionally **not** represented here.
38#[derive(
39    Debug,
40    Copy,
41    Clone,
42    PartialEq,
43    Eq,
44    PartialOrd,
45    Ord,
46    Hash,
47    Default,
48    Deserialize,
49    Serialize,
50    Constructor,
51)]
52pub struct MarginDetails {
53    /// Outstanding borrowed principal for the asset.
54    pub borrowed: Decimal,
55    /// Accrued, unpaid interest on the borrowed principal.
56    pub interest: Decimal,
57}
58
59impl MarginDetails {
60    /// What is owed of the asset: [`borrowed`](Self::borrowed) plus [`interest`](Self::interest).
61    pub fn owed(&self) -> Decimal {
62        self.borrowed + self.interest
63    }
64}
65
66/// Per-asset balance: gross holdings, the freely-tradable portion, optional margin debt and
67/// optional accrued carry.
68///
69/// Construct via [`Balance::new`] (cash) or [`Balance::new_margin`] (per-asset debt), then
70/// [`Balance::with_accrued`] where the venue reports an accrual. The struct is
71/// `#[non_exhaustive]`, so code outside this crate builds it only through those, and later fields
72/// are additive. `total` is gross holdings and `free` the freely-tradable portion; the remainder
73/// (`total - free`, exposed by [`Balance::used`]) is reserved against resting orders. Producers
74/// keep `free <= total`.
75///
76/// `margin: None` denotes a cash / no-debt context, **not** "unknown debt" — see [`Balance::margin`]
77/// and [`Balance::net_asset`].
78///
79/// `Ord`/`PartialOrd` are derived and compare lexicographically over
80/// `(total, free, margin, accrued)` (`None` sorts before `Some`); they exist for use as collection
81/// keys and generic bounds, not as a financial ranking. Use [`Balance::net_asset`] for value
82/// comparisons that account for debt.
83#[derive(
84    Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
85)]
86#[non_exhaustive]
87pub struct Balance {
88    /// Gross holdings of the asset (`free + locked`). Unaffected by any borrowing — debt is
89    /// carried separately in [`Balance::margin`].
90    ///
91    /// Exception: a venue whose margin is account-level (Alpaca, IBKR) reports only the account's
92    /// cash here, which is negative while the account borrows, with `margin` left `None`. See
93    /// the client's `account_snapshot`.
94    ///
95    /// A carrying cost (funding, margin interest, a borrow fee) reaches `total` only once the venue
96    /// posts it to the account, hourly at some venues and monthly at others. Until then `total`
97    /// leaves it out. A venue that reports the pending amount puts it in [`Balance::accrued`], or,
98    /// for interest on a loan, in [`MarginDetails::interest`]. Each client's rustdoc says what its
99    /// venue posts and when.
100    pub total: Decimal,
101    /// Portion of `total` available to trade (not reserved against resting orders).
102    pub free: Decimal,
103    /// Per-asset margin debt, present only for venues that report it (see [`MarginDetails`]).
104    ///
105    /// `None` means a cash / no-debt context — **not** "unknown debt". The REST account snapshot
106    /// always populates this for a margin account, and the WS partial update ([`BalanceUpdate`])
107    /// structurally cannot clear it, so `None` reliably denotes "no margin facility for this
108    /// asset". See [`Balance::net_asset`].
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub margin: Option<MarginDetails>,
111    /// Carry accrued to date and not yet posted to `total`: interest earned or charged, and
112    /// whatever else the venue accrues the same way. Signed: positive is an amount the account
113    /// will receive, negative one it will pay.
114    ///
115    /// The cash the account holds once the accrual posts is `total + accrued`. The venue resets
116    /// the accrual as it posts it, so the two never count the same amount twice. `None` means the
117    /// venue does not report one, not that nothing has accrued.
118    ///
119    /// This differs from [`MarginDetails::interest`], which is interest owed on a loan of the
120    /// asset and is paid out of a later repayment, not added to cash.
121    ///
122    /// It is only as fresh as the last
123    /// [`BalanceSnapshot`](crate::AccountEventKind::BalanceSnapshot) for the asset: a
124    /// [`BalanceUpdate`] carries none, and [`Balance::apply_stream_update`] keeps the prior value.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub accrued: Option<Decimal>,
127}
128
129impl Balance {
130    /// Construct a cash [`Balance`] with no margin debt (`margin: None`).
131    pub const fn new(total: Decimal, free: Decimal) -> Self {
132        Self {
133            total,
134            free,
135            margin: None,
136            accrued: None,
137        }
138    }
139
140    /// Construct a margin [`Balance`] carrying per-asset `borrowed`/`interest` debt.
141    pub const fn new_margin(
142        total: Decimal,
143        free: Decimal,
144        borrowed: Decimal,
145        interest: Decimal,
146    ) -> Self {
147        Self {
148            total,
149            free,
150            margin: Some(MarginDetails { borrowed, interest }),
151            accrued: None,
152        }
153    }
154
155    /// Set [`Balance::accrued`], the carry accrued and not yet posted to `total`.
156    #[must_use]
157    pub const fn with_accrued(mut self, accrued: Decimal) -> Self {
158        self.accrued = Some(accrued);
159        self
160    }
161
162    /// Portion of `total` reserved against resting orders (`total - free`).
163    pub fn used(&self) -> Decimal {
164        self.total - self.free
165    }
166
167    /// Net asset value: gross holdings less everything owed on the asset's loan.
168    ///
169    /// Returns `total` when [`Balance::margin`] is `None` and `total - borrowed - interest` when
170    /// `Some`, as Binance defines its margin accounts' `netAsset`. The result can be
171    /// **negative**: a short position is a negative net holding in the borrowed (base) asset.
172    ///
173    /// `None` is a cash / no-debt context, where net asset is simply the gross holding, except on
174    /// a venue whose margin is account-level (Alpaca, IBKR): there `total` is already the
175    /// account's cash, negative while it borrows (see [`Balance::total`]), and the result is that
176    /// figure, with no loan or interest itemised.
177    ///
178    /// Accrued [`MarginDetails::interest`] is owed as the principal is, so it is deducted once
179    /// the venue reports it accrued, not when it is repaid. Repaying the loan out of the asset's
180    /// holdings leaves the result unchanged, since `total` falls by what the debt does. For the
181    /// principal alone, read [`MarginDetails::borrowed`].
182    ///
183    /// [`Balance::accrued`] is not counted: it is carry the venue posts to `total` later, not a
184    /// debt of the loan.
185    ///
186    /// # Freshness
187    /// The returned value reflects debt only as fresh as the last
188    /// [`BalanceSnapshot`](crate::AccountEventKind::BalanceSnapshot) or
189    /// [`MarginDebt`](crate::AccountEventKind::MarginDebt) for this asset. WS partial updates keep
190    /// `free`/`locked` live but never carry debt, so on a venue that streams no debt changes a
191    /// position **borrowed since the last snapshot** reads as zero-debt (`net_asset == total`)
192    /// until the next snapshot. Interest is likewise held at its last reported total: it falls in
193    /// steps as snapshots or debt events report it, not continuously as the venue accrues it.
194    /// Each venue client says whether it streams debt changes.
195    pub fn net_asset(&self) -> Decimal {
196        match self.margin {
197            Some(margin) => self.total - margin.owed(),
198            None => self.total,
199        }
200    }
201
202    /// Merge a WS partial [`BalanceUpdate`] onto an optional prior [`Balance`], preserving debt.
203    ///
204    /// Applies the stream's live `free`/`locked` (recomputing `total = free + locked`) while
205    /// carrying forward any [`MarginDetails`] and [`Balance::accrued`] from `prior` — a partial
206    /// update carries neither, so this is the single-sourced "debt and accruals fresh as of last
207    /// snapshot, `free`/`locked` live over the stream" merge. On a cold start (`prior = None`) the
208    /// result has `margin: None` and `accrued: None`.
209    ///
210    /// This is the same no-clobber merge the engine applies internally for asset-keyed balances. It
211    /// is exposed so consumers applying per-instrument
212    /// [`InstrumentBalanceUpdate`](crate::InstrumentBalanceUpdate) frames — which the engine does
213    /// not store — can reuse it rather than re-implement the contract.
214    pub fn apply_stream_update(prior: Option<Balance>, update: &BalanceUpdate) -> Balance {
215        Balance {
216            total: update.total(),
217            free: update.free,
218            margin: prior.and_then(|b| b.margin),
219            accrued: prior.and_then(|b| b.accrued),
220        }
221    }
222}
223
224/// Partial balance update carrying only `free`/`locked` — the shape delivered by exchange WS
225/// user-data streams (e.g. Binance `outboundAccountPosition`, which reports `a`/`f`/`l` only).
226///
227/// Deliberately has **no** `margin` field: applying a `BalanceUpdate` therefore *structurally
228/// cannot* clobber known per-asset debt. `borrowed`/`interest` totals arrive via the REST
229/// [`BalanceSnapshot`](crate::AccountEventKind::BalanceSnapshot), and from a venue that streams
230/// them, via [`MarginDebt`](crate::AccountEventKind::MarginDebt).
231#[derive(
232    Debug,
233    Copy,
234    Clone,
235    PartialEq,
236    Eq,
237    PartialOrd,
238    Ord,
239    Hash,
240    Default,
241    Deserialize,
242    Serialize,
243    Constructor,
244)]
245pub struct BalanceUpdate {
246    /// Portion of holdings available to trade.
247    pub free: Decimal,
248    /// Portion of holdings reserved against resting orders.
249    pub locked: Decimal,
250}
251
252impl BalanceUpdate {
253    /// Gross holdings of the asset (`free + locked`).
254    pub fn total(&self) -> Decimal {
255        self.free + self.locked
256    }
257}
258
259/// Per-asset WS partial balance update (the [`BalanceUpdate`] counterpart of [`AssetBalance`]).
260#[derive(
261    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
262)]
263pub struct AssetBalanceUpdate<AssetKey> {
264    /// Asset the update applies to (exchange-native name or resolved index).
265    pub asset: AssetKey,
266    /// Partial `free`/`locked` payload delivered by the WS stream.
267    pub update: BalanceUpdate,
268    /// Exchange-reported timestamp of the update.
269    pub time_exchange: DateTime<Utc>,
270}
271
272#[cfg(test)]
273#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
274mod tests {
275    use super::*;
276    use rust_decimal_macros::dec;
277
278    #[test]
279    fn net_asset_cash_returns_total() {
280        // No margin facility → net asset is simply the gross holding.
281        let balance = Balance::new(dec!(100), dec!(80));
282        assert_eq!(balance.net_asset(), dec!(100));
283        assert_eq!(balance.used(), dec!(20));
284    }
285
286    #[test]
287    fn net_asset_margin_deducts_borrowed_and_interest() {
288        // total - borrowed - interest.
289        let balance = Balance::new_margin(dec!(100), dec!(100), dec!(30), dec!(2));
290        assert_eq!(balance.net_asset(), dec!(68));
291    }
292
293    #[test]
294    fn net_asset_short_is_negative() {
295        // A short borrows the base asset and sells it: holdings are ~0 but debt remains, so net
296        // asset is negative.
297        let balance = Balance::new_margin(dec!(0), dec!(0), dec!(1.5), dec!(0.001));
298        assert_eq!(balance.net_asset(), dec!(-1.501));
299    }
300
301    #[test]
302    fn net_asset_is_unchanged_by_a_repayment_and_ignores_accrued() {
303        // 100 held, 30 borrowed, 2 interest: repaying all 32 out of holdings leaves 68 either way.
304        let owing = Balance::new_margin(dec!(100), dec!(100), dec!(30), dec!(2));
305        let repaid = Balance::new_margin(dec!(68), dec!(68), dec!(0), dec!(0));
306        assert_eq!(owing.net_asset(), repaid.net_asset());
307
308        // Accrued carry posts to `total` later; it is not a debt of the loan.
309        assert_eq!(owing.with_accrued(dec!(-5)).net_asset(), dec!(68));
310    }
311
312    #[test]
313    fn balance_update_total() {
314        let update = BalanceUpdate::new(dec!(1.5), dec!(0.5));
315        assert_eq!(update.total(), dec!(2.0));
316        assert_eq!(update.locked, dec!(0.5));
317    }
318
319    #[test]
320    fn balance_default_has_no_margin() {
321        assert_eq!(Balance::default().margin, None);
322    }
323
324    #[test]
325    fn balance_serde_omits_margin_when_none() {
326        let json = serde_json::to_string(&Balance::new(dec!(1), dec!(1))).unwrap();
327        assert!(
328            !json.contains("margin"),
329            "cash balance must not serialise a margin field"
330        );
331
332        let margin = Balance::new_margin(dec!(1), dec!(1), dec!(0.5), dec!(0));
333        let round_trip: Balance =
334            serde_json::from_str(&serde_json::to_string(&margin).unwrap()).unwrap();
335        assert_eq!(round_trip, margin);
336    }
337
338    #[test]
339    fn balance_serde_accrued_is_optional() {
340        let json = serde_json::to_string(&Balance::new(dec!(1), dec!(1))).unwrap();
341        assert!(
342            !json.contains("accrued"),
343            "a balance without an accrual must not serialise the field"
344        );
345        // A balance stored before the field existed still loads.
346        let stored: Balance = serde_json::from_str(r#"{"total":"1","free":"1"}"#).unwrap();
347        assert_eq!(stored.accrued, None);
348
349        let accrued = Balance::new(dec!(1), dec!(1)).with_accrued(dec!(-0.25));
350        let round_trip: Balance =
351            serde_json::from_str(&serde_json::to_string(&accrued).unwrap()).unwrap();
352        assert_eq!(round_trip, accrued);
353        assert_eq!(round_trip.accrued, Some(dec!(-0.25)));
354    }
355
356    #[test]
357    fn apply_stream_update_keeps_debt_and_accrual() {
358        let prior =
359            Balance::new_margin(dec!(10), dec!(10), dec!(3), dec!(0.1)).with_accrued(dec!(1.5));
360        let merged =
361            Balance::apply_stream_update(Some(prior), &BalanceUpdate::new(dec!(4), dec!(2)));
362        assert_eq!(merged.total, dec!(6));
363        assert_eq!(merged.free, dec!(4));
364        assert_eq!(merged.margin, prior.margin);
365        assert_eq!(merged.accrued, Some(dec!(1.5)));
366
367        let cold = Balance::apply_stream_update(None, &BalanceUpdate::new(dec!(4), dec!(2)));
368        assert_eq!((cold.margin, cold.accrued), (None, None));
369    }
370}