Skip to main content

hypercall_types/
liquidation_state.rs

1//! Liquidation state data types.
2//!
3//! Pure data types for liquidation state that can be shared across crates.
4//! The full state machine logic (transitions, cache, etc.) remains in the root crate.
5
6use crate::MarginMode;
7use crate::WalletAddress;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use std::str::FromStr;
11
12/// Canonical liquidation mode persisted for projections and restart recovery.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum LiquidationMode {
16    Partial,
17    Full,
18}
19
20impl LiquidationMode {
21    pub fn as_str(self) -> &'static str {
22        match self {
23            Self::Partial => "partial",
24            Self::Full => "full",
25        }
26    }
27}
28
29/// Metadata for backend-managed partial liquidation.
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub struct PartialLiquidationMetadata {
33    /// Timestamp when partial liquidation started (millis).
34    pub entered_at: u64,
35    /// Current MM shortfall.
36    pub mm_shortfall: Decimal,
37    /// Health target used when sizing liquidation slices.
38    pub target_equity: Decimal,
39    /// Deadline after which the account escalates to full liquidation.
40    pub escalation_deadline: u64,
41    /// Last time active orders were repriced.
42    pub last_reprice_at: Option<u64>,
43    /// Active backend request IDs for partial liquidation orders.
44    pub active_order_request_ids: Vec<String>,
45    /// Active liquidation order client IDs.
46    pub active_order_client_ids: Vec<String>,
47    /// Current liquidation bonus used to mark buyback orders.
48    pub bonus_bps: u32,
49    /// Backend-generated auction identifier for a pending full-liquidation request.
50    pub pending_full_auction_id: Option<String>,
51    /// Request ID for a pending `StartLiquidation` directive while still awaiting on-chain start.
52    pub pending_full_request_id: Option<String>,
53    /// Chain transaction hash for a pending `StartLiquidation` directive, when confirmed.
54    pub pending_full_tx_hash: Option<String>,
55    /// Margin needed computed for the pending full-liquidation request.
56    pub pending_full_margin_needed: Option<Decimal>,
57}
58
59impl Default for PartialLiquidationMetadata {
60    fn default() -> Self {
61        Self {
62            entered_at: 0,
63            mm_shortfall: Decimal::ZERO,
64            target_equity: Decimal::ZERO,
65            escalation_deadline: 0,
66            last_reprice_at: None,
67            active_order_request_ids: Vec::new(),
68            active_order_client_ids: Vec::new(),
69            bonus_bps: 0,
70            pending_full_auction_id: None,
71            pending_full_request_id: None,
72            pending_full_tx_hash: None,
73            pending_full_margin_needed: None,
74        }
75    }
76}
77
78impl PartialLiquidationMetadata {
79    pub fn has_pending_full_liquidation(&self) -> bool {
80        self.pending_full_auction_id.is_some() || self.pending_full_request_id.is_some()
81    }
82}
83
84/// Metadata for an active full liquidation auction.
85#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub struct FullLiquidationMetadata {
88    /// Backend-generated auction identifier.
89    pub auction_id: String,
90    /// Request ID for the signed `StartLiquidation` directive.
91    pub request_id: Option<String>,
92    /// Chain transaction hash for the start directive once known.
93    pub tx_hash: Option<String>,
94    /// Local timestamp when the backend entered full-liquidation tracking.
95    pub started_at: u64,
96    /// On-chain auction `startTime` captured from `LiquidationStarted`.
97    pub chain_start_time: Option<u64>,
98    /// Margin needed passed to the Exchange.
99    pub margin_needed: Decimal,
100    /// Request ID for a pending stop directive.
101    pub stop_request_id: Option<String>,
102    /// Chain transaction hash for the stop directive once known.
103    pub stop_tx_hash: Option<String>,
104}
105
106impl Default for FullLiquidationMetadata {
107    fn default() -> Self {
108        Self {
109            auction_id: String::new(),
110            request_id: None,
111            tx_hash: None,
112            started_at: 0,
113            chain_start_time: None,
114            margin_needed: Decimal::ZERO,
115            stop_request_id: None,
116            stop_tx_hash: None,
117        }
118    }
119}
120
121/// Metadata for a resolved full liquidation.
122#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub struct LiquidatedMetadata {
125    /// Backend-generated auction identifier.
126    pub auction_id: String,
127    /// Resolution timestamp (millis).
128    pub completed_at: u64,
129    /// Winning manager, when known.
130    pub winner: Option<WalletAddress>,
131    /// Insurance-fund bonus credited to the account.
132    pub bonus: Decimal,
133    /// Resolution transaction hash, when known.
134    pub tx_hash: Option<String>,
135}
136
137impl Default for LiquidatedMetadata {
138    fn default() -> Self {
139        Self {
140            auction_id: String::new(),
141            completed_at: 0,
142            winner: None,
143            bonus: Decimal::ZERO,
144            tx_hash: None,
145        }
146    }
147}
148
149/// Liquidation state for an account.
150#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(tag = "state", rename_all = "snake_case")]
152#[derive(Default)]
153pub enum LiquidationState {
154    #[default]
155    Healthy,
156    PreLiquidation(PartialLiquidationMetadata),
157    InLiquidation(FullLiquidationMetadata),
158    Liquidated(LiquidatedMetadata),
159}
160
161/// Database string constants for liquidation states.
162pub mod state_str {
163    pub const HEALTHY: &str = "healthy";
164    pub const PRE_LIQUIDATION: &str = "pre_liquidation";
165    pub const IN_LIQUIDATION: &str = "in_liquidation";
166    pub const LIQUIDATED: &str = "liquidated";
167}
168
169/// Error type for parsing liquidation state from string.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ParseLiquidationStateError(pub String);
172
173impl std::fmt::Display for ParseLiquidationStateError {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        write!(f, "unknown liquidation state: {}", self.0)
176    }
177}
178
179impl std::error::Error for ParseLiquidationStateError {}
180
181impl LiquidationState {
182    pub fn is_pre_liquidation(&self) -> bool {
183        matches!(self, Self::PreLiquidation(..))
184    }
185
186    pub fn is_in_liquidation(&self) -> bool {
187        matches!(self, Self::InLiquidation(..))
188    }
189
190    pub fn is_liquidated(&self) -> bool {
191        matches!(self, Self::Liquidated(..))
192    }
193
194    pub fn is_healthy(&self) -> bool {
195        matches!(self, Self::Healthy)
196    }
197
198    pub fn should_block_risk_increasing(&self) -> bool {
199        matches!(self, Self::PreLiquidation(..) | Self::InLiquidation(..))
200    }
201
202    pub fn auction_id(&self) -> Option<&str> {
203        match self {
204            Self::PreLiquidation(metadata) => metadata.pending_full_auction_id.as_deref(),
205            Self::InLiquidation(metadata) => Some(metadata.auction_id.as_str()),
206            Self::Liquidated(metadata) => Some(metadata.auction_id.as_str()),
207            _ => None,
208        }
209    }
210
211    pub fn liquidation_mode(&self) -> Option<LiquidationMode> {
212        match self {
213            Self::Healthy => None,
214            Self::PreLiquidation(metadata) => Some(if metadata.has_pending_full_liquidation() {
215                LiquidationMode::Full
216            } else {
217                LiquidationMode::Partial
218            }),
219            Self::InLiquidation(..) | Self::Liquidated(..) => Some(LiquidationMode::Full),
220        }
221    }
222
223    /// Human-readable state label retained for API/UI compatibility.
224    pub fn as_str(&self) -> &'static str {
225        match self {
226            Self::Healthy => "Healthy",
227            Self::PreLiquidation(..) => "PreLiquidation",
228            Self::InLiquidation(..) => "InLiquidation",
229            Self::Liquidated(..) => "Liquidated",
230        }
231    }
232
233    pub fn db_str(&self) -> &'static str {
234        match self {
235            Self::Healthy => state_str::HEALTHY,
236            Self::PreLiquidation(..) => state_str::PRE_LIQUIDATION,
237            Self::InLiquidation(..) => state_str::IN_LIQUIDATION,
238            Self::Liquidated(..) => state_str::LIQUIDATED,
239        }
240    }
241}
242
243pub fn has_material_projection_change(
244    previous: &LiquidationState,
245    current: &LiquidationState,
246) -> bool {
247    match (previous, current) {
248        (LiquidationState::Healthy, LiquidationState::Healthy) => false,
249        (LiquidationState::PreLiquidation(previous), LiquidationState::PreLiquidation(current)) => {
250            previous.mm_shortfall != current.mm_shortfall
251                || previous.target_equity != current.target_equity
252                || previous.escalation_deadline != current.escalation_deadline
253                || previous.last_reprice_at != current.last_reprice_at
254                || previous.active_order_request_ids != current.active_order_request_ids
255                || previous.active_order_client_ids != current.active_order_client_ids
256                || previous.bonus_bps != current.bonus_bps
257                || previous.pending_full_auction_id != current.pending_full_auction_id
258                || previous.pending_full_request_id != current.pending_full_request_id
259                || previous.pending_full_tx_hash != current.pending_full_tx_hash
260                || previous.pending_full_margin_needed != current.pending_full_margin_needed
261        }
262        (LiquidationState::InLiquidation(previous), LiquidationState::InLiquidation(current)) => {
263            previous.auction_id != current.auction_id
264                || previous.request_id != current.request_id
265                || previous.tx_hash != current.tx_hash
266                || previous.chain_start_time != current.chain_start_time
267                || previous.margin_needed != current.margin_needed
268                || previous.stop_request_id != current.stop_request_id
269                || previous.stop_tx_hash != current.stop_tx_hash
270        }
271        (LiquidationState::Liquidated(previous), LiquidationState::Liquidated(current)) => {
272            previous.auction_id != current.auction_id
273                || previous.completed_at != current.completed_at
274                || previous.winner != current.winner
275                || previous.bonus != current.bonus
276                || previous.tx_hash != current.tx_hash
277        }
278        _ => true,
279    }
280}
281
282impl FromStr for LiquidationState {
283    type Err = ParseLiquidationStateError;
284
285    fn from_str(s: &str) -> Result<Self, Self::Err> {
286        match s {
287            state_str::HEALTHY => Ok(Self::Healthy),
288            state_str::PRE_LIQUIDATION => {
289                Ok(Self::PreLiquidation(PartialLiquidationMetadata::default()))
290            }
291            state_str::IN_LIQUIDATION => {
292                Ok(Self::InLiquidation(FullLiquidationMetadata::default()))
293            }
294            state_str::LIQUIDATED => Ok(Self::Liquidated(LiquidatedMetadata::default())),
295            _ => Err(ParseLiquidationStateError(s.to_string())),
296        }
297    }
298}
299
300impl std::fmt::Display for LiquidationState {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        match self {
303            Self::Healthy => write!(f, "Healthy"),
304            Self::PreLiquidation(metadata) => write!(
305                f,
306                "PreLiquidation(shortfall={}, target_equity={}, orders={}, entered_at={})",
307                metadata.mm_shortfall,
308                metadata.target_equity,
309                metadata.active_order_client_ids.len(),
310                metadata.entered_at
311            ),
312            Self::InLiquidation(metadata) => write!(
313                f,
314                "InLiquidation(auction={}, margin_needed={}, chain_start_time={:?})",
315                metadata.auction_id, metadata.margin_needed, metadata.chain_start_time
316            ),
317            Self::Liquidated(metadata) => write!(
318                f,
319                "Liquidated(auction={}, completed_at={}, winner={:?}, bonus={})",
320                metadata.auction_id, metadata.completed_at, metadata.winner, metadata.bonus
321            ),
322        }
323    }
324}
325
326/// Complete liquidation status for an account.
327#[derive(Clone, Debug, Serialize, Deserialize)]
328pub struct AccountLiquidationStatus {
329    pub wallet: WalletAddress,
330    pub state: LiquidationState,
331    pub margin_mode: MarginMode,
332    pub equity: Decimal,
333    pub mm_required: Decimal,
334    pub maintenance_margin: Decimal,
335    pub updated_at: u64,
336}
337
338impl AccountLiquidationStatus {
339    pub fn healthy(
340        wallet: WalletAddress,
341        margin_mode: MarginMode,
342        equity: Decimal,
343        mm_required: Decimal,
344        timestamp: u64,
345    ) -> Self {
346        Self {
347            wallet,
348            state: LiquidationState::Healthy,
349            margin_mode,
350            equity,
351            mm_required,
352            maintenance_margin: equity - mm_required,
353            updated_at: timestamp,
354        }
355    }
356
357    pub fn needs_liquidation(&self) -> bool {
358        self.maintenance_margin < Decimal::ZERO
359    }
360
361    pub fn shortfall(&self) -> Decimal {
362        if self.maintenance_margin < Decimal::ZERO {
363            -self.maintenance_margin
364        } else {
365            Decimal::ZERO
366        }
367    }
368
369    pub fn liquidation_mode(&self) -> Option<LiquidationMode> {
370        self.state.liquidation_mode()
371    }
372
373    pub fn enter_pre_liquidation(&mut self, metadata: PartialLiquidationMetadata, timestamp: u64) {
374        self.state = LiquidationState::PreLiquidation(metadata);
375        self.updated_at = timestamp;
376    }
377
378    pub fn recover_to_healthy(&mut self, timestamp: u64) {
379        self.state = LiquidationState::Healthy;
380        self.updated_at = timestamp;
381    }
382
383    pub fn enter_liquidation(&mut self, metadata: FullLiquidationMetadata, timestamp: u64) {
384        self.state = LiquidationState::InLiquidation(metadata);
385        self.updated_at = timestamp;
386    }
387
388    pub fn complete_liquidation(&mut self, metadata: LiquidatedMetadata, timestamp: u64) {
389        self.state = LiquidationState::Liquidated(metadata);
390        self.updated_at = timestamp;
391    }
392
393    pub fn update_health(&mut self, equity: Decimal, mm_required: Decimal, timestamp: u64) {
394        self.equity = equity;
395        self.mm_required = mm_required;
396        self.maintenance_margin = equity - mm_required;
397        self.updated_at = timestamp;
398        let shortfall = self.shortfall();
399
400        if let LiquidationState::PreLiquidation(metadata) = &mut self.state {
401            metadata.mm_shortfall = shortfall;
402        }
403    }
404}