Skip to main content

hypercall_admin/monitoring/
liquidation.rs

1//! Liquidation monitoring dashboard admin endpoint.
2
3use axum::{extract::State, http::StatusCode, response::IntoResponse};
4use rust_decimal_macros::dec;
5use sonic_rs::json;
6use std::collections::HashMap;
7use tracing::warn;
8
9use crate::state::AdminState;
10use hypercall_db::LiquidationReader;
11use hypercall_runtime_api::sonic_json::SonicJson;
12use hypercall_types::WalletAddress;
13
14/// Liquidation monitoring dashboard.
15/// Shows all accounts with positions ranked by margin ratio, plus
16/// any active liquidation states and recent transitions.
17pub async fn liquidation_dashboard(State(app_state): State<AdminState>) -> impl IntoResponse {
18    use sonic_rs::JsonValueTrait;
19
20    let portfolio_cache = &app_state.portfolio_cache;
21    let tier_cache = &app_state.tier_cache;
22
23    let portfolios = portfolio_cache.get_all_portfolios().await;
24
25    let liquidation_reader: &dyn LiquidationReader = app_state.db.as_ref();
26    let recent_history = liquidation_reader
27        .get_recent_liquidation_history(20)
28        .await
29        .unwrap_or_default();
30
31    let liq_records = match liquidation_reader.get_all_liquidation_states().await {
32        Ok(r) => r,
33        Err(error) => {
34            warn!("Failed to load liquidation states: {}", error);
35            return (
36                StatusCode::INTERNAL_SERVER_ERROR,
37                SonicJson(
38                    json!({ "error": format!("failed to load liquidation states: {}", error) }),
39                ),
40            )
41                .into_response();
42        }
43    };
44
45    // Build wallet → DB state lookup
46    let liq_state_map: HashMap<WalletAddress, &_> =
47        liq_records.iter().map(|r| (r.wallet_address, r)).collect();
48
49    let mut state_counts: HashMap<String, usize> = HashMap::new();
50    let mut accounts: Vec<sonic_rs::Value> = Vec::new();
51
52    // Show all accounts with positions, overlaying DB liquidation state
53    for (wallet, summary) in &portfolios {
54        if summary.positions.is_empty() {
55            continue;
56        }
57
58        let (margin_mode, margin_mode_error) = match tier_cache.get_margin_mode_sync(wallet) {
59            Ok(mode) => (Some(mode), None),
60            Err(error) => {
61                warn!("Failed to load margin mode for {}: {}", wallet, error);
62                (None, Some(error.to_string()))
63            }
64        };
65
66        let (state, equity, mm_required, maintenance_margin) =
67            if let Some(record) = liq_state_map.get(wallet) {
68                (
69                    record.state.clone(),
70                    record.equity,
71                    record.mm_required,
72                    record.maintenance_margin,
73                )
74            } else if let Some(info) = &summary.margin_info {
75                (
76                    "Healthy".to_string(),
77                    info.equity,
78                    info.maintenance_margin,
79                    info.equity - info.maintenance_margin,
80                )
81            } else {
82                continue;
83            };
84
85        *state_counts.entry(state.clone()).or_default() += 1;
86
87        let margin_ratio = if mm_required > dec!(0) {
88            equity / mm_required
89        } else if equity > dec!(0) {
90            dec!(999)
91        } else {
92            dec!(0)
93        };
94
95        let mut account = json!({
96            "wallet": wallet.as_hex(),
97            "state": state,
98            "equity": equity.round_dp(2).to_string(),
99            "mm_required": mm_required.round_dp(2).to_string(),
100            "maintenance_margin": maintenance_margin.round_dp(2).to_string(),
101            "margin_ratio": margin_ratio.round_dp(4).to_string(),
102            "margin_mode": margin_mode
103                .map(|mode| mode.as_str())
104                .unwrap_or("unknown"),
105            "position_count": summary.positions.len(),
106        });
107        if let Some(error) = margin_mode_error {
108            account["margin_mode_error"] = json!(error);
109        }
110        accounts.push(account);
111    }
112
113    // Also include DB-only accounts not in portfolio cache
114    for record in &liq_records {
115        if portfolios.contains_key(&record.wallet_address) {
116            continue;
117        }
118        *state_counts.entry(record.state.clone()).or_default() += 1;
119        let margin_ratio = if record.mm_required > dec!(0) {
120            record.equity / record.mm_required
121        } else {
122            dec!(999)
123        };
124        accounts.push(json!({
125            "wallet": record.wallet_address.as_hex(),
126            "state": record.state,
127            "equity": record.equity.round_dp(2).to_string(),
128            "mm_required": record.mm_required.round_dp(2).to_string(),
129            "maintenance_margin": record.maintenance_margin.round_dp(2).to_string(),
130            "margin_ratio": margin_ratio.round_dp(4).to_string(),
131            "margin_mode": "unknown",
132            "position_count": 0,
133        }));
134    }
135
136    accounts.sort_by(|a, b| {
137        let ratio_a: f64 = a["margin_ratio"]
138            .as_str()
139            .and_then(|s| s.parse().ok())
140            .unwrap_or(999.0);
141        let ratio_b: f64 = b["margin_ratio"]
142            .as_str()
143            .and_then(|s| s.parse().ok())
144            .unwrap_or(999.0);
145        ratio_a
146            .partial_cmp(&ratio_b)
147            .unwrap_or(std::cmp::Ordering::Equal)
148    });
149
150    let history_entries: Vec<sonic_rs::Value> = recent_history
151        .iter()
152        .map(|h| {
153            json!({
154                "wallet": h.wallet_address.as_hex(),
155                "previous_state": h.previous_state,
156                "new_state": h.new_state,
157                "equity": h.equity.to_string(),
158                "shortfall": h.shortfall.to_string(),
159                "auction_id": h.auction_id,
160                "created_at": h.created_at.map(|t| t.to_string()),
161            })
162        })
163        .collect();
164
165    SonicJson(json!({
166        "accounts_at_risk": accounts.iter().take(50).collect::<Vec<_>>(),
167        "total_tracked": accounts.len(),
168        "state_counts": state_counts,
169        "recent_transitions": history_entries,
170        "config": {
171            "enabled": std::env::var("LIQUIDATION_ENABLED").unwrap_or_else(|_| "false".to_string()),
172            "min_shortfall_threshold": std::env::var("LIQUIDATION_MIN_SHORTFALL_THRESHOLD").unwrap_or_else(|_| "0".to_string()),
173        }
174    }))
175    .into_response()
176}