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, and optional margin debt.
50///
51/// Construct via [`Balance::new`] (cash) or [`Balance::new_margin`] (per-asset debt). `total` is
52/// gross holdings and `free` the freely-tradable portion; the remainder (`total - free`, exposed
53/// by [`Balance::used`]) is reserved against resting orders. Callers building the struct literally
54/// must keep `free <= total`.
55///
56/// `margin: None` denotes a cash / no-debt context, **not** "unknown debt" — see [`Balance::margin`]
57/// and [`Balance::net_asset`].
58///
59/// `Ord`/`PartialOrd` are derived and compare lexicographically over `(total, free, margin)`
60/// (`None` sorts before `Some`); they exist for use as collection keys and generic bounds, not as a
61/// financial ranking. Use [`Balance::net_asset`] for value comparisons that account for debt.
62#[derive(
63 Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
64)]
65pub struct Balance {
66 /// Gross holdings of the asset (`free + locked`). Unaffected by any borrowing — debt is
67 /// carried separately in [`Balance::margin`].
68 ///
69 /// Exception: a venue whose margin is account-level (Alpaca, IBKR) reports only the account's
70 /// cash here, which is negative while the account borrows, with `margin` left `None`. See
71 /// the client's `account_snapshot`.
72 ///
73 /// A carrying cost (funding, margin interest, a borrow fee) reaches `total` only once the venue
74 /// posts it to the account, hourly at some venues and monthly at others. Until then no figure
75 /// here carries it, except the accrued [`MarginDetails::interest`] of a venue that reports
76 /// one. Each client's rustdoc says what its venue posts and when.
77 pub total: Decimal,
78 /// Portion of `total` available to trade (not reserved against resting orders).
79 pub free: Decimal,
80 /// Per-asset margin debt, present only for venues that report it (see [`MarginDetails`]).
81 ///
82 /// `None` means a cash / no-debt context — **not** "unknown debt". The REST account snapshot
83 /// always populates this for a margin account, and the WS partial update ([`BalanceUpdate`])
84 /// structurally cannot clear it, so `None` reliably denotes "no margin facility for this
85 /// asset". See [`Balance::net_asset`].
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub margin: Option<MarginDetails>,
88}
89
90impl Balance {
91 /// Construct a cash [`Balance`] with no margin debt (`margin: None`).
92 pub const fn new(total: Decimal, free: Decimal) -> Self {
93 Self {
94 total,
95 free,
96 margin: None,
97 }
98 }
99
100 /// Construct a margin [`Balance`] carrying per-asset `borrowed`/`interest` debt.
101 pub const fn new_margin(
102 total: Decimal,
103 free: Decimal,
104 borrowed: Decimal,
105 interest: Decimal,
106 ) -> Self {
107 Self {
108 total,
109 free,
110 margin: Some(MarginDetails { borrowed, interest }),
111 }
112 }
113
114 /// Portion of `total` reserved against resting orders (`total - free`).
115 pub fn used(&self) -> Decimal {
116 self.total - self.free
117 }
118
119 /// Net asset value after deducting borrowed principal.
120 ///
121 /// Returns `total` when [`Balance::margin`] is `None` (a cash / no-debt context, where net
122 /// asset is simply the gross holding) and `total - borrowed` when `Some`. The result can be
123 /// **negative** — a short position is a negative net holding in the borrowed (base) asset.
124 ///
125 /// Accrued [`MarginDetails::interest`] is intentionally **excluded**: it is tracked separately
126 /// and does not reduce net holdings until the venue actually deducts it.
127 ///
128 /// # Freshness
129 /// The returned value reflects debt only as fresh as the last
130 /// [`BalanceSnapshot`](crate::AccountEventKind::BalanceSnapshot) for this asset. WS partial
131 /// updates keep `free`/`locked` live but never carry debt, so a position **borrowed since the
132 /// last snapshot** reads as zero-debt (`net_asset == total`) until the next snapshot. Consumers
133 /// needing exact live debt should react to the venue's borrow/repay event by refreshing the
134 /// account snapshot.
135 pub fn net_asset(&self) -> Decimal {
136 match self.margin {
137 Some(margin) => self.total - margin.borrowed,
138 None => self.total,
139 }
140 }
141
142 /// Merge a WS partial [`BalanceUpdate`] onto an optional prior [`Balance`], preserving debt.
143 ///
144 /// Applies the stream's live `free`/`locked` (recomputing `total = free + locked`) while
145 /// carrying forward any [`MarginDetails`] from `prior` — a partial update never carries debt, so
146 /// this is the single-sourced "debt fresh as of last snapshot, `free`/`locked` live over the
147 /// stream" merge. On a cold start (`prior = None`) the result has `margin: None`.
148 ///
149 /// This is the same no-clobber merge the engine applies internally for asset-keyed balances. It
150 /// is exposed so consumers applying per-instrument
151 /// [`InstrumentBalanceUpdate`](crate::InstrumentBalanceUpdate) frames — which the engine does
152 /// not store — can reuse it rather than re-implement the contract.
153 pub fn apply_stream_update(prior: Option<Balance>, update: &BalanceUpdate) -> Balance {
154 Balance {
155 total: update.total(),
156 free: update.free,
157 margin: prior.and_then(|b| b.margin),
158 }
159 }
160}
161
162/// Partial balance update carrying only `free`/`locked` — the shape delivered by exchange WS
163/// user-data streams (e.g. Binance `outboundAccountPosition`, which reports `a`/`f`/`l` only).
164///
165/// Deliberately has **no** `margin` field: applying a `BalanceUpdate` therefore *structurally
166/// cannot* clobber known per-asset debt. Authoritative `borrowed`/`interest` totals arrive only via
167/// the REST [`BalanceSnapshot`](crate::AccountEventKind::BalanceSnapshot).
168#[derive(
169 Debug,
170 Copy,
171 Clone,
172 PartialEq,
173 Eq,
174 PartialOrd,
175 Ord,
176 Hash,
177 Default,
178 Deserialize,
179 Serialize,
180 Constructor,
181)]
182pub struct BalanceUpdate {
183 /// Portion of holdings available to trade.
184 pub free: Decimal,
185 /// Portion of holdings reserved against resting orders.
186 pub locked: Decimal,
187}
188
189impl BalanceUpdate {
190 /// Gross holdings of the asset (`free + locked`).
191 pub fn total(&self) -> Decimal {
192 self.free + self.locked
193 }
194}
195
196/// Per-asset WS partial balance update (the [`BalanceUpdate`] counterpart of [`AssetBalance`]).
197#[derive(
198 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Constructor,
199)]
200pub struct AssetBalanceUpdate<AssetKey> {
201 /// Asset the update applies to (exchange-native name or resolved index).
202 pub asset: AssetKey,
203 /// Partial `free`/`locked` payload delivered by the WS stream.
204 pub update: BalanceUpdate,
205 /// Exchange-reported timestamp of the update.
206 pub time_exchange: DateTime<Utc>,
207}
208
209#[cfg(test)]
210#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
211mod tests {
212 use super::*;
213 use rust_decimal_macros::dec;
214
215 #[test]
216 fn net_asset_cash_returns_total() {
217 // No margin facility → net asset is simply the gross holding.
218 let balance = Balance::new(dec!(100), dec!(80));
219 assert_eq!(balance.net_asset(), dec!(100));
220 assert_eq!(balance.used(), dec!(20));
221 }
222
223 #[test]
224 fn net_asset_margin_deducts_borrowed() {
225 // total - borrowed; interest does not affect net asset.
226 let balance = Balance::new_margin(dec!(100), dec!(100), dec!(30), dec!(2));
227 assert_eq!(balance.net_asset(), dec!(70));
228 }
229
230 #[test]
231 fn net_asset_short_is_negative() {
232 // A short borrows the base asset and sells it: holdings are ~0 but debt remains, so net
233 // asset is negative.
234 let balance = Balance::new_margin(dec!(0), dec!(0), dec!(1.5), dec!(0.001));
235 assert_eq!(balance.net_asset(), dec!(-1.5));
236 }
237
238 #[test]
239 fn balance_update_total() {
240 let update = BalanceUpdate::new(dec!(1.5), dec!(0.5));
241 assert_eq!(update.total(), dec!(2.0));
242 assert_eq!(update.locked, dec!(0.5));
243 }
244
245 #[test]
246 fn balance_default_has_no_margin() {
247 assert_eq!(Balance::default().margin, None);
248 }
249
250 #[test]
251 fn balance_serde_omits_margin_when_none() {
252 let json = serde_json::to_string(&Balance::new(dec!(1), dec!(1))).unwrap();
253 assert!(
254 !json.contains("margin"),
255 "cash balance must not serialise a margin field"
256 );
257
258 let margin = Balance::new_margin(dec!(1), dec!(1), dec!(0.5), dec!(0));
259 let round_trip: Balance =
260 serde_json::from_str(&serde_json::to_string(&margin).unwrap()).unwrap();
261 assert_eq!(round_trip, margin);
262 }
263}