Skip to main content

hypercall/liquidator/
health_check.rs

1//! Liquidation health check utilities.
2//!
3//! Provides mode-aware health checks for determining if an account
4//! should be liquidated based on their margin mode (Standard vs Portfolio).
5
6use crate::rsm::margin_service::MarginService;
7use crate::rsm::MarginMode;
8use crate::standard_margin::StandardMarginService;
9use crate::types::Account;
10use hypercall_margin::standard::StandardAccount;
11use rust_decimal::prelude::ToPrimitive;
12
13/// Result of a liquidation health check.
14#[derive(Debug, Clone)]
15pub struct LiquidationHealthResult {
16    /// Wallet address
17    pub wallet: String,
18    /// Margin mode
19    pub margin_mode: MarginMode,
20    /// Whether the account is liquidatable
21    pub is_liquidatable: bool,
22    /// Maintenance margin (excess, negative = liquidatable)
23    pub maintenance_margin: f64,
24    /// Account equity
25    pub equity: f64,
26    /// Required maintenance margin
27    pub mm_required: f64,
28}
29
30impl LiquidationHealthResult {
31    /// Check if account needs liquidation.
32    pub fn needs_liquidation(&self) -> bool {
33        self.is_liquidatable
34    }
35
36    /// Get the shortfall amount (how much below MM).
37    pub fn shortfall(&self) -> f64 {
38        if self.maintenance_margin < 0.0 {
39            -self.maintenance_margin
40        } else {
41            0.0
42        }
43    }
44}
45
46/// Check liquidation health for a Portfolio margin account.
47///
48/// Portfolio margin uses SPAN-based scenario evaluation.
49/// Liquidation triggers when: equity < maintenance_margin_required
50pub async fn check_portfolio_health<M: MarginService>(
51    wallet: &str,
52    account: &Account,
53    margin_service: &M,
54) -> LiquidationHealthResult {
55    let details = margin_service.compute_margin_for_account(account).await;
56
57    match details {
58        Ok(d) => {
59            let maintenance_margin = d.equity - d.maintenance_margin_required;
60            let maintenance_margin_f64 = maintenance_margin.to_f64().unwrap_or(0.0);
61            LiquidationHealthResult {
62                wallet: wallet.to_string(),
63                margin_mode: MarginMode::Portfolio,
64                is_liquidatable: maintenance_margin_f64 < 0.0,
65                maintenance_margin: maintenance_margin_f64,
66                equity: d.equity.to_f64().unwrap_or(0.0),
67                mm_required: d.maintenance_margin_required.to_f64().unwrap_or(0.0),
68            }
69        }
70        Err(_) => {
71            // Fail closed: if margin computation fails (e.g. vol oracle down),
72            // treat the account as potentially liquidatable so it gets flagged
73            // for review rather than silently skipped.
74            LiquidationHealthResult {
75                wallet: wallet.to_string(),
76                margin_mode: MarginMode::Portfolio,
77                is_liquidatable: true,
78                maintenance_margin: -1.0,
79                equity: account.cash,
80                mm_required: f64::MAX,
81            }
82        }
83    }
84}
85
86/// Check liquidation health for a Standard margin account.
87///
88/// Standard margin uses linear per-position margin.
89/// Liquidation triggers when: maintenance_margin < 0
90pub fn check_standard_health(
91    wallet: &str,
92    account: &StandardAccount,
93    margin_service: &StandardMarginService,
94) -> LiquidationHealthResult {
95    let result = margin_service.compute_margin(account);
96
97    LiquidationHealthResult {
98        wallet: wallet.to_string(),
99        margin_mode: MarginMode::Standard,
100        is_liquidatable: result.is_liquidatable(),
101        maintenance_margin: result.maintenance_margin.to_f64().unwrap_or(0.0),
102        equity: result.equity.to_f64().unwrap_or(0.0),
103        mm_required: result.position_mm.to_f64().unwrap_or(0.0),
104    }
105}
106
107/// Mode-aware liquidation health check.
108///
109/// Branches on margin mode to use the appropriate margin calculation.
110///
111/// # Usage
112///
113/// ```rust,ignore
114/// let mode = tier_cache.get_margin_mode_sync(wallet);
115/// let health = match mode {
116///     MarginMode::Portfolio => {
117///         let account = risk_account_builder.build_executed_account_for_risk(wallet).await?;
118///         check_portfolio_health(wallet, &account, &span_margin_service).await
119///     }
120///     MarginMode::Standard => {
121///         let account = standard_account_builder.build(wallet).await?;
122///         check_standard_health(wallet, &account, &standard_margin_service)
123///     }
124/// };
125///
126/// if health.needs_liquidation() {
127///     trigger_liquidation(wallet, health.shortfall());
128/// }
129/// ```
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::standard_margin::StandardMarginService;
134    use hypercall_margin::standard::{OptionPosition, StandardAccount};
135    use rust_decimal_macros::dec;
136
137    #[test]
138    fn test_standard_health_check_healthy() {
139        let service = StandardMarginService::new();
140        let account = StandardAccount::new("test".to_string(), dec!(10000));
141
142        let result = check_standard_health("test", &account, &service);
143
144        assert!(!result.needs_liquidation());
145        assert_eq!(result.shortfall(), 0.0);
146        assert_eq!(result.margin_mode, MarginMode::Standard);
147    }
148
149    #[test]
150    fn test_standard_health_check_liquidatable() {
151        let service = StandardMarginService::new();
152        let mut account = StandardAccount::new("test".to_string(), dec!(100)); // Very low balance
153
154        // Add a short option that will require significant margin
155        account.option_positions.push(OptionPosition {
156            symbol: "ETH-20251231-3500-C".to_string(),
157            underlying: "ETH".to_string(),
158            expiry_ts: 0,
159            strike: dec!(3500),
160            is_call: true,
161            size: dec!(-10),
162            mark_price: dec!(500),
163            entry_price: dec!(200), // Underwater
164            spot_price: dec!(3500),
165        });
166
167        let result = check_standard_health("test", &account, &service);
168
169        assert!(result.needs_liquidation());
170        assert!(result.shortfall() > 0.0);
171    }
172}