Skip to main content

rustrade_execution/
notice.rs

1//! Notices a venue sends about an account's state that are not trades, orders or balances: margin
2//! calls, liquidation warnings, a liquidation under way, and the all-clear after them.
3//!
4//! An account stream reports each one as
5//! [`AccountEventKind::Notice`](crate::AccountEventKind::Notice), carrying an [`AccountNotice`].
6//! The library delivers the notice; what to do about it (stop opening positions, reduce exposure,
7//! alert someone) stays the consumer's.
8
9use chrono::{DateTime, Utc};
10use derive_more::Constructor;
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use smol_str::SmolStr;
14use std::fmt::{Display, Formatter};
15
16/// A notice from the venue about the account's margin or liquidation state.
17///
18/// Sent as [`AccountEventKind::Notice`](crate::AccountEventKind::Notice). A notice reports a
19/// change of state, not a delta: it changes no balance or position, and the engine only logs it.
20///
21/// A notice can be missed: one sent while the stream is disconnected is not recovered when it
22/// reconnects. A consumer that acts on a [`NoticeKind::MarginCall`] and waits for a
23/// [`NoticeKind::MarginRestored`] should re-check the venue's state after a reconnect, rather than
24/// wait for a notice that may already have gone by.
25///
26/// # Producers
27///
28/// Known producers, as of writing:
29/// - Binance margin (`BinanceMargin`): `marginLevelChange`. See the client's rustdoc for how
30///   its status maps to a [`NoticeKind`], and which instrument an isolated pair's notice names.
31/// - The simulated venue
32///   ([`SimulatedVenue`](crate::exchange::mock::SimulatedVenue)), when its spot margin account is
33///   configured with a [`SpotMarginLiquidation`](crate::margin::SpotMarginLiquidation): a
34///   liquidation, and an optional margin call and its restoration, for the account as a whole.
35#[non_exhaustive]
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, Constructor)]
37pub struct AccountNotice<InstrumentKey> {
38    /// What the notice says, independent of venue.
39    pub kind: NoticeKind,
40    /// The instrument the notice is about, when the venue scopes it to one, as it does an isolated
41    /// margin pair. `None` for a notice about the account as a whole, and for one the producer
42    /// could not attribute; each producer says which it sends.
43    pub instrument: Option<InstrumentKey>,
44    /// When the venue sent the notice.
45    pub time_exchange: DateTime<Utc>,
46    /// The venue's margin level when it sent the notice, when it reports one.
47    ///
48    /// It is the venue's own figure under the venue's own definition, so it is not comparable
49    /// across venues. Each producer says what it is. `None` when the venue sent none, or sent one
50    /// that could not be read.
51    pub margin_level: Option<Decimal>,
52    /// The venue's own name for the status, verbatim, such as Binance's `PRE_LIQUIDATION`.
53    pub status: SmolStr,
54}
55
56/// What an [`AccountNotice`] says, independent of venue.
57///
58/// Each producer documents how its venue's statuses map onto these. A status that maps to none
59/// is sent as [`Other`](Self::Other), with the venue's name for it in
60/// [`AccountNotice::status`].
61#[non_exhaustive]
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
63pub enum NoticeKind {
64    /// The account's margin has fallen to the venue's margin-call level.
65    MarginCall,
66    /// The account's margin is close to the level at which the venue liquidates.
67    LiquidationWarning,
68    /// The venue has started liquidating the account's positions.
69    Liquidation,
70    /// A margin call or liquidation condition the venue reported earlier no longer holds.
71    MarginRestored,
72    /// A status that no other variant names. [`AccountNotice::status`] carries the venue's name
73    /// for it.
74    Other,
75}
76
77impl NoticeKind {
78    /// Whether the notice reports rising risk: a margin call, a liquidation warning or a
79    /// liquidation.
80    pub fn is_escalation(self) -> bool {
81        matches!(
82            self,
83            Self::MarginCall | Self::LiquidationWarning | Self::Liquidation
84        )
85    }
86}
87
88impl Display for NoticeKind {
89    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
90        f.write_str(match self {
91            Self::MarginCall => "margin call",
92            Self::LiquidationWarning => "liquidation warning",
93            Self::Liquidation => "liquidation",
94            Self::MarginRestored => "margin restored",
95            Self::Other => "other",
96        })
97    }
98}
99
100#[cfg(test)]
101#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
102mod tests {
103    use super::*;
104
105    #[test]
106    fn only_rising_risk_is_an_escalation() {
107        for (kind, escalation) in [
108            (NoticeKind::MarginCall, true),
109            (NoticeKind::LiquidationWarning, true),
110            (NoticeKind::Liquidation, true),
111            (NoticeKind::MarginRestored, false),
112            (NoticeKind::Other, false),
113        ] {
114            assert_eq!(kind.is_escalation(), escalation, "{kind}");
115        }
116    }
117
118    #[test]
119    fn a_notice_round_trips_through_serde() {
120        let notice = AccountNotice::new(
121            NoticeKind::LiquidationWarning,
122            Some(SmolStr::new("BTCUSDT")),
123            DateTime::from_timestamp_millis(1_700_000_000_000).unwrap(),
124            Some(Decimal::new(115, 2)),
125            SmolStr::new("PRE_LIQUIDATION"),
126        );
127        let json = serde_json::to_string(&notice).unwrap();
128        assert_eq!(
129            serde_json::from_str::<AccountNotice<SmolStr>>(&json).unwrap(),
130            notice
131        );
132    }
133}