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#[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(¬ice).unwrap();
124 assert_eq!(
125 serde_json::from_str::<AccountNotice<SmolStr>>(&json).unwrap(),
126 notice
127 );
128 }
129}