hypercall_admin/monitoring/
liquidation.rs1use 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
14pub 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 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 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 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}