hypercall/observability/metrics_collector/
risk.rs1use super::*;
2use crate::read_cache::portfolio::{PortfolioSummary, WalletMarginSnapshot};
3use hypercall_types::WalletAddress;
4
5struct RiskyAccount {
6 wallet_short: String,
7 margin_utilization: f64,
8 equity: f64,
9}
10
11#[derive(Default)]
12struct RiskMetricsSnapshot {
13 total_equity: f64,
14 total_margin_required: f64,
15 accounts_near_liquidation: i64,
16 accounts_in_breach: i64,
17 accounts_negative_available: i64,
18 accounts_safe: i64,
19 accounts_warning: i64,
20 accounts_danger: i64,
21 accounts_critical: i64,
22 equity_under_1k: i64,
23 equity_1k_10k: i64,
24 equity_10k_100k: i64,
25 equity_100k_1m: i64,
26 equity_over_1m: i64,
27 max_position_notional: f64,
28 max_account_equity: f64,
29 position_conversion_failures: i64,
30 risky_accounts: Vec<RiskyAccount>,
31 margin_failures: i64,
32}
33
34impl RiskMetricsSnapshot {
35 fn margin_utilization(&self) -> f64 {
36 if self.total_equity > 0.0 {
37 (self.total_margin_required / self.total_equity * 100.0).min(100.0)
38 } else {
39 0.0
40 }
41 }
42
43 fn record_margin_snapshot(
44 &mut self,
45 wallet: &WalletAddress,
46 snapshot: &WalletMarginSnapshot,
47 near_liquidation_threshold: f64,
48 ) {
49 let Some(values) = RiskMarginValues::from_snapshot(snapshot) else {
50 warn!(
51 wallet = %wallet,
52 "Margin decimal conversion failed, skipping wallet"
53 );
54 self.margin_failures += 1;
55 return;
56 };
57
58 self.total_equity += values.equity;
59 self.total_margin_required += values.maintenance_margin_required;
60 self.max_account_equity = self.max_account_equity.max(values.equity);
61
62 self.record_breach_state(wallet, &values);
63 self.record_margin_utilization(wallet, &values, near_liquidation_threshold);
64 }
65
66 fn record_breach_state(&mut self, wallet: &WalletAddress, values: &RiskMarginValues) {
67 if values.maintenance_margin_required > 0.0
68 && values.equity < values.maintenance_margin_required
69 {
70 self.accounts_in_breach += 1;
71 warn!(
72 "ACCOUNT IN BREACH: {} has equity ${:.2} < MM ${:.2}",
73 wallet, values.equity, values.maintenance_margin_required
74 );
75 }
76
77 if values.available_balance < 0.0 {
78 self.accounts_negative_available += 1;
79 warn!(
80 "NEGATIVE AVAILABLE: {} has available ${:.2} (equity ${:.2}, IM/reserved ${:.2}, margin excess ${:.2})",
81 wallet,
82 values.available_balance,
83 values.equity,
84 values.initial_margin_required,
85 values.initial_margin_excess
86 );
87 }
88 }
89
90 fn record_margin_utilization(
91 &mut self,
92 wallet: &WalletAddress,
93 values: &RiskMarginValues,
94 near_liquidation_threshold: f64,
95 ) {
96 if values.equity <= 0.0 {
97 return;
98 }
99
100 let margin_utilization =
101 (values.maintenance_margin_required / values.equity * 100.0).min(100.0);
102 self.record_risk_level(margin_utilization);
103 self.record_equity_bucket(values.equity);
104
105 if values.maintenance_margin_required > 0.0 {
106 self.risky_accounts.push(RiskyAccount {
107 wallet_short: short_wallet(wallet),
108 margin_utilization,
109 equity: values.equity,
110 });
111
112 let margin_buffer = (values.equity - values.maintenance_margin_required)
113 / values.maintenance_margin_required;
114 if margin_buffer < near_liquidation_threshold && margin_buffer > 0.0 {
115 self.accounts_near_liquidation += 1;
116 }
117 }
118 }
119
120 fn record_risk_level(&mut self, margin_utilization: f64) {
121 if margin_utilization < 50.0 {
122 self.accounts_safe += 1;
123 } else if margin_utilization < 80.0 {
124 self.accounts_warning += 1;
125 } else if margin_utilization < 95.0 {
126 self.accounts_danger += 1;
127 } else {
128 self.accounts_critical += 1;
129 }
130 }
131
132 fn record_equity_bucket(&mut self, equity: f64) {
133 if equity < 1_000.0 {
134 self.equity_under_1k += 1;
135 } else if equity < 10_000.0 {
136 self.equity_1k_10k += 1;
137 } else if equity < 100_000.0 {
138 self.equity_10k_100k += 1;
139 } else if equity < 1_000_000.0 {
140 self.equity_100k_1m += 1;
141 } else {
142 self.equity_over_1m += 1;
143 }
144 }
145
146 fn record_positions(&mut self, portfolio: &PortfolioSummary) {
147 for position in portfolio.positions.values() {
148 let Some(amount) = position.amount.to_f64() else {
149 warn!(
150 symbol = %position.symbol,
151 amount = %position.amount,
152 "Failed to convert position amount for risk exposure metrics"
153 );
154 self.position_conversion_failures += 1;
155 continue;
156 };
157 let Some(entry_price) = position.entry_price.to_f64() else {
158 warn!(
159 symbol = %position.symbol,
160 entry_price = %position.entry_price,
161 "Failed to convert entry price for risk exposure metrics"
162 );
163 self.position_conversion_failures += 1;
164 continue;
165 };
166 let amount = amount.abs();
167 self.max_position_notional = self.max_position_notional.max(amount * entry_price);
168 }
169 }
170
171 fn top_risky_accounts(&mut self) -> Vec<RiskyAccount> {
172 self.risky_accounts.sort_by(|a, b| {
173 b.margin_utilization
174 .partial_cmp(&a.margin_utilization)
175 .unwrap_or(std::cmp::Ordering::Equal)
176 });
177 self.risky_accounts
178 .drain(..self.risky_accounts.len().min(5))
179 .collect()
180 }
181}
182
183struct RiskMarginValues {
184 equity: f64,
185 maintenance_margin_required: f64,
186 initial_margin_required: f64,
187 available_balance: f64,
188 initial_margin_excess: f64,
189}
190
191impl RiskMarginValues {
192 fn from_snapshot(snapshot: &WalletMarginSnapshot) -> Option<Self> {
193 Some(Self {
194 equity: snapshot.margin_summary.equity.to_f64()?,
195 maintenance_margin_required: snapshot
196 .span_margin
197 .maintenance_margin_required
198 .to_f64()?,
199 initial_margin_required: snapshot.total_margin_used.to_f64()?,
200 available_balance: snapshot.available_balance.to_f64()?,
201 initial_margin_excess: snapshot.margin_summary.initial_margin.to_f64()?,
202 })
203 }
204}
205
206impl MetricsCollector {
207 pub(super) async fn collect_risk_metrics(&self) {
210 let portfolios = self.portfolio_cache.get_all_portfolios().await;
211 let mut snapshot = RiskMetricsSnapshot::default();
212
213 for (wallet, portfolio) in &portfolios {
214 match self
215 .portfolio_cache
216 .compute_wallet_margin_snapshot(wallet)
217 .await
218 {
219 Ok(margin_snapshot) => snapshot.record_margin_snapshot(
220 wallet,
221 &margin_snapshot,
222 self.config.near_liquidation_threshold,
223 ),
224 Err(e) => {
225 warn!(
226 wallet = %wallet,
227 error = %e,
228 "Failed to compute wallet margin snapshot"
229 );
230 snapshot.margin_failures += 1;
231 }
232 }
233
234 snapshot.record_positions(portfolio);
235 }
236
237 emit_risk_metrics(&mut snapshot);
238 log_risk_summary(&snapshot);
239 }
240}
241
242fn emit_risk_metrics(snapshot: &mut RiskMetricsSnapshot) {
243 let margin_utilization = snapshot.margin_utilization();
244
245 gauge!("ht_margin_utilization_percent").set(margin_utilization);
246 gauge!("ht_accounts_near_liquidation").set(snapshot.accounts_near_liquidation as f64);
247 gauge!("ht_total_equity_usd").set(snapshot.total_equity);
248 gauge!("ht_total_margin_required_usd").set(snapshot.total_margin_required);
249 gauge!("ht_margin_calc_failures").set(snapshot.margin_failures as f64);
250 gauge!("ht_risk_metrics_complete").set(if snapshot.margin_failures == 0 {
251 1.0
252 } else {
253 0.0
254 });
255
256 gauge!("ht_accounts_in_breach").set(snapshot.accounts_in_breach as f64);
257 gauge!("ht_accounts_negative_available").set(snapshot.accounts_negative_available as f64);
258
259 gauge!("ht_accounts_by_risk_level", "level" => "safe").set(snapshot.accounts_safe as f64);
260 gauge!("ht_accounts_by_risk_level", "level" => "warning").set(snapshot.accounts_warning as f64);
261 gauge!("ht_accounts_by_risk_level", "level" => "danger").set(snapshot.accounts_danger as f64);
262 gauge!("ht_accounts_by_risk_level", "level" => "critical")
263 .set(snapshot.accounts_critical as f64);
264
265 gauge!("ht_accounts_by_equity", "bucket" => "<1k").set(snapshot.equity_under_1k as f64);
266 gauge!("ht_accounts_by_equity", "bucket" => "1k-10k").set(snapshot.equity_1k_10k as f64);
267 gauge!("ht_accounts_by_equity", "bucket" => "10k-100k").set(snapshot.equity_10k_100k as f64);
268 gauge!("ht_accounts_by_equity", "bucket" => "100k-1M").set(snapshot.equity_100k_1m as f64);
269 gauge!("ht_accounts_by_equity", "bucket" => ">1M").set(snapshot.equity_over_1m as f64);
270
271 gauge!("ht_max_position_notional_usd").set(snapshot.max_position_notional);
272 gauge!("ht_max_account_equity_usd").set(snapshot.max_account_equity);
273 gauge!("ht_risk_position_conversion_failures")
274 .set(snapshot.position_conversion_failures as f64);
275
276 let top_risky = snapshot.top_risky_accounts();
277 emit_top_risky_accounts(&top_risky);
278 log_danger_accounts(&top_risky);
279}
280
281fn emit_top_risky_accounts(top_risky: &[RiskyAccount]) {
282 for (rank, account) in top_risky.iter().enumerate() {
283 let rank_str = (rank + 1).to_string();
284 gauge!(
285 "ht_top_risky_account_margin_pct",
286 "rank" => rank_str.clone(),
287 "wallet" => account.wallet_short.clone()
288 )
289 .set(account.margin_utilization);
290 gauge!(
291 "ht_top_risky_account_equity",
292 "rank" => rank_str,
293 "wallet" => account.wallet_short.clone()
294 )
295 .set(account.equity);
296 }
297}
298
299fn log_danger_accounts(top_risky: &[RiskyAccount]) {
300 let danger_accounts: Vec<_> = top_risky
301 .iter()
302 .filter(|account| account.margin_utilization >= 80.0)
303 .collect();
304 if danger_accounts.is_empty() {
305 return;
306 }
307
308 warn!(
309 "⚠️ {} accounts in danger zone: {:?}",
310 danger_accounts.len(),
311 danger_accounts
312 .iter()
313 .map(|account| format!(
314 "{}: {:.1}%",
315 account.wallet_short, account.margin_utilization
316 ))
317 .collect::<Vec<_>>()
318 );
319}
320
321fn log_risk_summary(snapshot: &RiskMetricsSnapshot) {
322 if snapshot.margin_failures > 0 {
323 warn!(
324 "Risk metrics omitted {} accounts because margin calculation failed; aggregate gauges may be partial",
325 snapshot.margin_failures
326 );
327 }
328
329 debug!(
330 "Risk metrics: {:.1}% margin util, {} near-liq, risk levels: safe={} warn={} danger={} crit={}",
331 snapshot.margin_utilization(),
332 snapshot.accounts_near_liquidation,
333 snapshot.accounts_safe,
334 snapshot.accounts_warning,
335 snapshot.accounts_danger,
336 snapshot.accounts_critical
337 );
338}
339
340fn short_wallet(wallet: &WalletAddress) -> String {
341 let wallet_str = wallet.to_string();
342 if wallet_str.len() > 12 {
343 format!(
344 "{}...{}",
345 &wallet_str[..6],
346 &wallet_str[wallet_str.len() - 4..]
347 )
348 } else {
349 wallet_str
350 }
351}