rustrade_execution/margin_debt.rs
1//! A change in what a margin account owes in one asset, as the venue streams it: a borrow, a repay
2//! or an interest charge.
3//!
4//! An account stream reports each one as
5//! [`AccountEventKind::MarginDebt`](crate::AccountEventKind::MarginDebt), carrying a
6//! [`MarginDebtUpdate`]. The engine applies one that names no instrument to the asset's
7//! [`Balance::margin`](crate::balance::Balance::margin), so debt stays current between REST
8//! account snapshots.
9use crate::balance::MarginDetails;
10use chrono::{DateTime, Utc};
11use derive_more::Constructor;
12use serde::{Deserialize, Serialize};
13use smol_str::SmolStr;
14use std::fmt::{Display, Formatter};
15
16/// What a margin account owes in one asset after a change, as the venue reported it.
17///
18/// Sent as [`AccountEventKind::MarginDebt`](crate::AccountEventKind::MarginDebt).
19///
20/// # Totals, not deltas
21///
22/// [`debt`](Self::debt) is everything owed in [`asset`](Self::asset) once the change is applied:
23/// the whole principal and the whole unpaid interest, not the amount the change borrowed, repaid or
24/// charged. Applying an update therefore replaces the debt the consumer held, and applying the same
25/// update twice changes nothing. A debt repaid in full is reported as zero, not left out.
26///
27/// # Ordering
28///
29/// A venue may send these on a channel of their own, unordered against the account's fills and
30/// balance updates. Order them by [`time_exchange`](Self::time_exchange), not by arrival, and
31/// against debt alone: a newer free/locked balance update says nothing about debt. An update can
32/// be missed while a stream is disconnected; a REST account snapshot restates the debt.
33///
34/// # Producers
35///
36/// Known producers, as of writing:
37/// - Binance margin (`BinanceMargin`): `liabilityChange`, one update per asset it lists. See the
38/// client's rustdoc for how its type maps to a [`MarginDebtKind`], and why an isolated pair's
39/// debt is not known to be streamed.
40/// - The simulated venue (`SimulatedVenue`) on a spot-margin account, after each borrow, repay and
41/// interest charge.
42#[non_exhaustive]
43#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, Constructor)]
44pub struct MarginDebtUpdate<AssetKey, InstrumentKey> {
45 /// The asset owed.
46 pub asset: AssetKey,
47 /// The isolated margin pair the debt belongs to, or `None` for a debt of the account as a whole,
48 /// as on a cross margin account.
49 ///
50 /// The engine applies only an update with no instrument to its asset balances: an isolated
51 /// pair's balances are per pair, and the engine stores none of them (see
52 /// [`InstrumentBalanceUpdate`](crate::InstrumentBalanceUpdate)).
53 pub instrument: Option<InstrumentKey>,
54 /// The principal and interest owed in [`asset`](Self::asset) after the change, both totals.
55 pub debt: MarginDetails,
56 /// What changed the debt, independent of venue.
57 pub kind: MarginDebtKind,
58 /// The venue's own name for the change, verbatim, such as Binance's `BORROW`.
59 pub venue_kind: SmolStr,
60 /// When the venue made the change.
61 pub time_exchange: DateTime<Utc>,
62}
63
64/// What changed a [`MarginDebtUpdate`]'s debt, independent of venue.
65///
66/// Each producer documents how its venue's names map onto these. A name that maps to none is sent
67/// as [`Other`](Self::Other), with the venue's name in [`MarginDebtUpdate::venue_kind`].
68#[non_exhaustive]
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
70pub enum MarginDebtKind {
71 /// A loan was taken, or added to.
72 Borrow,
73 /// Principal or interest was repaid.
74 Repay,
75 /// Interest was charged on the principal owed.
76 Interest,
77 /// A change no other variant names. [`MarginDebtUpdate::venue_kind`] carries the venue's name
78 /// for it.
79 Other,
80}
81
82impl Display for MarginDebtKind {
83 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
84 f.write_str(match self {
85 Self::Borrow => "borrow",
86 Self::Repay => "repay",
87 Self::Interest => "interest",
88 Self::Other => "other",
89 })
90 }
91}
92
93#[cfg(test)]
94#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
95mod tests {
96 use super::*;
97 use chrono::TimeZone;
98 use rust_decimal_macros::dec;
99
100 #[test]
101 fn an_update_round_trips_through_serde() {
102 let update = MarginDebtUpdate::new(
103 SmolStr::new("BTC"),
104 Some(SmolStr::new("BTCUSDT")),
105 MarginDetails {
106 borrowed: dec!(0.00006),
107 interest: dec!(0.00000002),
108 },
109 MarginDebtKind::Interest,
110 SmolStr::new("INTEREST"),
111 Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
112 );
113 let json = serde_json::to_string(&update).unwrap();
114 assert_eq!(
115 serde_json::from_str::<MarginDebtUpdate<SmolStr, SmolStr>>(&json).unwrap(),
116 update
117 );
118 }
119
120 #[test]
121 fn a_kind_displays_as_its_lowercase_name() {
122 let names = [
123 MarginDebtKind::Borrow,
124 MarginDebtKind::Repay,
125 MarginDebtKind::Interest,
126 MarginDebtKind::Other,
127 ]
128 .map(|kind| kind.to_string());
129 assert_eq!(names, ["borrow", "repay", "interest", "other"]);
130 }
131}