Skip to main content

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}