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(¬ice).unwrap();
128 assert_eq!(
129 serde_json::from_str::<AccountNotice<SmolStr>>(&json).unwrap(),
130 notice
131 );
132 }
133}