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}