Skip to main content

hypercall/observability/metrics_collector/
risk.rs

1use 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    // ===== Risk Metrics =====
208
209    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}