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, as does a
33/// stream that restates its balances after reconnecting by itself (Binance's do).
34///
35/// # Producers
36///
37/// Known producers, as of writing:
38/// - Binance margin (`BinanceMargin`): `liabilityChange`, one update per asset it lists. See the
39/// client's rustdoc for how its type maps to a [`MarginDebtKind`], and why an isolated pair's
40/// debt is not known to be streamed.
41/// - The simulated venue (`SimulatedVenue`) on a spot-margin account, after each borrow, repay and
42/// interest charge.
43#[non_exhaustive]
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, Constructor)]
45pub struct MarginDebtUpdate<AssetKey, InstrumentKey> {
46 /// The asset owed.
47 pub asset: AssetKey,
48 /// The isolated margin pair the debt belongs to, or `None` for a debt of the account as a whole,
49 /// as on a cross margin account.
50 ///
51 /// The engine applies only an update with no instrument to its asset balances: an isolated
52 /// pair's balances are per pair, and the engine stores none of them (see
53 /// [`InstrumentBalanceUpdate`](crate::InstrumentBalanceUpdate)).
54 pub instrument: Option<InstrumentKey>,
55 /// The principal and interest owed in [`asset`](Self::asset) after the change, both totals.
56 pub debt: MarginDetails,
57 /// What changed the debt, independent of venue.
58 pub kind: MarginDebtKind,
59 /// The venue's own name for the change, verbatim, such as Binance's `BORROW`.
60 pub venue_kind: SmolStr,
61 /// When the venue made the change.
62 pub time_exchange: DateTime<Utc>,
63}
64
65/// What changed a [`MarginDebtUpdate`]'s debt, independent of venue.
66///
67/// Each producer documents how its venue's names map onto these. A name that maps to none is sent
68/// as [`Other`](Self::Other), with the venue's name in [`MarginDebtUpdate::venue_kind`].
69#[non_exhaustive]
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
71pub enum MarginDebtKind {
72 /// A loan was taken, or added to.
73 Borrow,
74 /// Principal or interest was repaid.
75 Repay,
76 /// Interest was charged on the principal owed.
77 Interest,
78 /// A change no other variant names. [`MarginDebtUpdate::venue_kind`] carries the venue's name
79 /// for it.
80 Other,
81}
82
83impl Display for MarginDebtKind {
84 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
85 f.write_str(match self {
86 Self::Borrow => "borrow",
87 Self::Repay => "repay",
88 Self::Interest => "interest",
89 Self::Other => "other",
90 })
91 }
92}
93
94#[cfg(test)]
95#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
96mod tests {
97 use super::*;
98 use chrono::TimeZone;
99 use rust_decimal_macros::dec;
100
101 #[test]
102 fn an_update_round_trips_through_serde() {
103 let update = MarginDebtUpdate::new(
104 SmolStr::new("BTC"),
105 Some(SmolStr::new("BTCUSDT")),
106 MarginDetails {
107 borrowed: dec!(0.00006),
108 interest: dec!(0.00000002),
109 },
110 MarginDebtKind::Interest,
111 SmolStr::new("INTEREST"),
112 Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
113 );
114 let json = serde_json::to_string(&update).unwrap();
115 assert_eq!(
116 serde_json::from_str::<MarginDebtUpdate<SmolStr, SmolStr>>(&json).unwrap(),
117 update
118 );
119 }
120
121 #[test]
122 fn a_kind_displays_as_its_lowercase_name() {
123 let names = [
124 MarginDebtKind::Borrow,
125 MarginDebtKind::Repay,
126 MarginDebtKind::Interest,
127 MarginDebtKind::Other,
128 ]
129 .map(|kind| kind.to_string());
130 assert_eq!(names, ["borrow", "repay", "interest", "other"]);
131 }
132}