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`): `MARGIN_LEVEL_STATUS_CHANGE`. See the client's rustdoc for
30///   which streams it is read from and how a notice sent on more than one is delivered once.
31#[non_exhaustive]
32#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, Constructor)]
33pub struct AccountNotice<InstrumentKey> {
34    /// What the notice says, independent of venue.
35    pub kind: NoticeKind,
36    /// The instrument the notice is about, when the venue scopes it to one, as it does an isolated
37    /// margin pair. `None` for a notice about the account as a whole, and for one the producer
38    /// could not attribute; each producer says which it sends.
39    pub instrument: Option<InstrumentKey>,
40    /// When the venue sent the notice.
41    pub time_exchange: DateTime<Utc>,
42    /// The venue's margin level when it sent the notice, when it reports one.
43    ///
44    /// It is the venue's own figure under the venue's own definition, so it is not comparable
45    /// across venues. Each producer says what it is. `None` when the venue sent none, or sent one
46    /// that could not be read.
47    pub margin_level: Option<Decimal>,
48    /// The venue's own name for the status, verbatim, such as Binance's `PRE_LIQUIDATION`.
49    pub status: SmolStr,
50}
51
52/// What an [`AccountNotice`] says, independent of venue.
53///
54/// Each producer documents how its venue's statuses map onto these. A status that maps to none
55/// is sent as [`Other`](Self::Other), with the venue's name for it in
56/// [`AccountNotice::status`].
57#[non_exhaustive]
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
59pub enum NoticeKind {
60    /// The account's margin has fallen to the venue's margin-call level.
61    MarginCall,
62    /// The account's margin is close to the level at which the venue liquidates.
63    LiquidationWarning,
64    /// The venue has started liquidating the account's positions.
65    Liquidation,
66    /// A margin call or liquidation condition the venue reported earlier no longer holds.
67    MarginRestored,
68    /// A status that no other variant names. [`AccountNotice::status`] carries the venue's name
69    /// for it.
70    Other,
71}
72
73impl NoticeKind {
74    /// Whether the notice reports rising risk: a margin call, a liquidation warning or a
75    /// liquidation.
76    pub fn is_escalation(self) -> bool {
77        matches!(
78            self,
79            Self::MarginCall | Self::LiquidationWarning | Self::Liquidation
80        )
81    }
82}
83
84impl Display for NoticeKind {
85    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
86        f.write_str(match self {
87            Self::MarginCall => "margin call",
88            Self::LiquidationWarning => "liquidation warning",
89            Self::Liquidation => "liquidation",
90            Self::MarginRestored => "margin restored",
91            Self::Other => "other",
92        })
93    }
94}
95
96#[cfg(test)]
97#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
98mod tests {
99    use super::*;
100
101    #[test]
102    fn only_rising_risk_is_an_escalation() {
103        for (kind, escalation) in [
104            (NoticeKind::MarginCall, true),
105            (NoticeKind::LiquidationWarning, true),
106            (NoticeKind::Liquidation, true),
107            (NoticeKind::MarginRestored, false),
108            (NoticeKind::Other, false),
109        ] {
110            assert_eq!(kind.is_escalation(), escalation, "{kind}");
111        }
112    }
113
114    #[test]
115    fn a_notice_round_trips_through_serde() {
116        let notice = AccountNotice::new(
117            NoticeKind::LiquidationWarning,
118            Some(SmolStr::new("BTCUSDT")),
119            DateTime::from_timestamp_millis(1_700_000_000_000).unwrap(),
120            Some(Decimal::new(115, 2)),
121            SmolStr::new("PRE_LIQUIDATION"),
122        );
123        let json = serde_json::to_string(&notice).unwrap();
124        assert_eq!(
125            serde_json::from_str::<AccountNotice<SmolStr>>(&json).unwrap(),
126            notice
127        );
128    }
129}