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