bybit/models/adl_alert_websocket.rs
1use crate::prelude::*;
2
3/// Represents an ADL (Auto-Deleveraging) alert item in a WebSocket stream.
4///
5/// ADL is a risk management mechanism that automatically closes positions
6/// when the insurance pool balance reaches certain thresholds to prevent
7/// systemic risk. This struct is used in WebSocket streams to provide
8/// real-time ADL alert information.
9///
10/// # Bybit API Reference
11/// The Bybit WebSocket API (https://bybit-exchange.github.io/docs/v5/websocket/public/adl-alert)
12/// provides ADL alert updates with a push frequency of 1 second.
13#[derive(Serialize, Deserialize, Clone, Debug)]
14#[serde(rename_all = "camelCase")]
15pub struct ADLAlertWebsocketItem {
16 /// The token of the insurance pool (e.g., "USDT", "USDC").
17 /// Specifies the currency used for the insurance pool.
18 #[serde(rename = "c")]
19 pub coin: String,
20
21 /// The trading pair name (e.g., "BTCUSDT").
22 /// Identifies the contract for which the ADL alert applies.
23 #[serde(rename = "s")]
24 pub symbol: String,
25
26 /// The balance of the insurance fund.
27 /// Used to determine if ADL is triggered. For shared insurance pools,
28 /// this field follows a T+1 refresh mechanism and is updated daily at 00:00 UTC.
29 /// When balance ≤ 0, insurance pool equity ADL is triggered.
30 #[serde(rename = "b")]
31 #[serde(with = "string_to_float")]
32 pub balance: f64,
33
34 /// The maximum balance of the insurance pool in the last 8 hours.
35 /// Note: According to the API documentation, this field is deprecated and always returns "".
36 /// It's included for compatibility but should not be relied upon.
37 #[serde(rename = "mb")]
38 #[serde(with = "string_to_float")]
39 pub max_balance: f64,
40
41 /// The PnL ratio threshold for triggering contract PnL drawdown ADL.
42 /// ADL is triggered when the symbol's PnL drawdown ratio in the last 8 hours
43 /// exceeds this value. Typically a negative value like "-0.3".
44 #[serde(rename = "i_pr")]
45 #[serde(with = "string_to_float")]
46 pub insurance_pnl_ratio: f64,
47
48 /// The symbol's PnL drawdown ratio in the last 8 hours.
49 /// Used to determine whether ADL is triggered or stopped.
50 /// Calculated as: (Symbol's current PnL - Symbol's 8h max PnL) / Insurance pool's 8h max balance.
51 #[serde(rename = "pr")]
52 #[serde(with = "string_to_float")]
53 pub pnl_ratio: f64,
54
55 /// The trigger threshold for contract PnL drawdown ADL.
56 /// This condition is only effective when the insurance pool balance is greater than this value.
57 /// If so, an 8-hour drawdown exceeding the insurance_pnl_ratio may trigger ADL.
58 /// Typically a value like "10000".
59 #[serde(rename = "adl_tt")]
60 #[serde(with = "string_to_float")]
61 pub adl_trigger_threshold: f64,
62
63 /// The stop ratio threshold for contract PnL drawdown ADL.
64 /// ADL stops when the symbol's 8-hour drawdown ratio falls below this value.
65 /// Typically a value like "-0.25".
66 #[serde(rename = "adl_sr")]
67 #[serde(with = "string_to_float")]
68 pub adl_stop_ratio: f64,
69}
70
71impl ADLAlertWebsocketItem {
72 /// Constructs a new ADLAlertWebsocketItem with specified parameters.
73 pub fn new(
74 coin: &str,
75 symbol: &str,
76 balance: f64,
77 max_balance: f64,
78 insurance_pnl_ratio: f64,
79 pnl_ratio: f64,
80 adl_trigger_threshold: f64,
81 adl_stop_ratio: f64,
82 ) -> Self {
83 Self {
84 coin: coin.to_string(),
85 symbol: symbol.to_string(),
86 balance,
87 max_balance,
88 insurance_pnl_ratio,
89 pnl_ratio,
90 adl_trigger_threshold,
91 adl_stop_ratio,
92 }
93 }
94
95 /// Returns true if this ADL alert item is for a specific coin.
96 pub fn is_coin(&self, coin: &str) -> bool {
97 self.coin.eq_ignore_ascii_case(coin)
98 }
99
100 /// Returns true if this ADL alert item is for a specific symbol.
101 pub fn is_symbol(&self, symbol: &str) -> bool {
102 self.symbol.eq_ignore_ascii_case(symbol)
103 }
104
105 /// Checks if contract PnL drawdown ADL should be triggered.
106 /// According to the API documentation, ADL is triggered when:
107 /// 1. `balance` > `adl_trigger_threshold`
108 /// 2. `pnl_ratio` < `insurance_pnl_ratio`
109 pub fn is_contract_pnl_drawdown_adl_triggered(&self) -> bool {
110 self.balance > self.adl_trigger_threshold && self.pnl_ratio < self.insurance_pnl_ratio
111 }
112
113 /// Checks if insurance pool equity ADL should be triggered.
114 /// According to the API documentation, ADL is triggered when:
115 /// `balance` ≤ 0
116 pub fn is_insurance_pool_equity_adl_triggered(&self) -> bool {
117 self.balance <= 0.0
118 }
119
120 /// Checks if contract PnL drawdown ADL should be stopped.
121 /// According to the API documentation, ADL stops when:
122 /// `pnl_ratio` > `adl_stop_ratio`
123 pub fn is_contract_pnl_drawdown_adl_stopped(&self) -> bool {
124 self.pnl_ratio > self.adl_stop_ratio
125 }
126
127 /// Checks if insurance pool equity ADL should be stopped.
128 /// According to the API documentation, ADL stops when:
129 /// `balance` > 0
130 pub fn is_insurance_pool_equity_adl_stopped(&self) -> bool {
131 self.balance > 0.0
132 }
133
134 /// Returns the ADL status for this item.
135 /// Returns a tuple of (contract_triggered, contract_stopped, equity_triggered, equity_stopped).
136 pub fn adl_status(&self) -> (bool, bool, bool, bool) {
137 (
138 self.is_contract_pnl_drawdown_adl_triggered(),
139 self.is_contract_pnl_drawdown_adl_stopped(),
140 self.is_insurance_pool_equity_adl_triggered(),
141 self.is_insurance_pool_equity_adl_stopped(),
142 )
143 }
144
145 /// Returns true if any ADL condition is currently triggered.
146 pub fn is_any_adl_triggered(&self) -> bool {
147 self.is_contract_pnl_drawdown_adl_triggered()
148 || self.is_insurance_pool_equity_adl_triggered()
149 }
150
151 /// Returns true if all ADL conditions are stopped.
152 pub fn is_all_adl_stopped(&self) -> bool {
153 self.is_contract_pnl_drawdown_adl_stopped() && self.is_insurance_pool_equity_adl_stopped()
154 }
155
156 /// Returns the absolute value of the balance.
157 pub fn absolute_balance(&self) -> f64 {
158 self.balance.abs()
159 }
160
161 /// Returns a string representation of the balance with sign.
162 pub fn signed_balance_string(&self) -> String {
163 if self.balance >= 0.0 {
164 format!("+{:.8}", self.balance)
165 } else {
166 format!("{:.8}", self.balance)
167 }
168 }
169
170 /// Returns the drawdown amount relative to the insurance PnL ratio threshold.
171 pub fn drawdown_amount(&self) -> f64 {
172 self.insurance_pnl_ratio - self.pnl_ratio
173 }
174
175 /// Returns true if the drawdown exceeds the threshold.
176 pub fn is_drawdown_exceeding_threshold(&self) -> bool {
177 self.drawdown_amount() > 0.0
178 }
179
180 /// Returns the safety margin before ADL trigger.
181 pub fn safety_margin(&self) -> f64 {
182 if self.balance > self.adl_trigger_threshold {
183 self.balance - self.adl_trigger_threshold
184 } else {
185 0.0
186 }
187 }
188
189 /// Returns the safety margin as a percentage of trigger threshold.
190 pub fn safety_margin_percentage(&self) -> f64 {
191 if self.adl_trigger_threshold > 0.0 {
192 self.safety_margin() / self.adl_trigger_threshold * 100.0
193 } else {
194 0.0
195 }
196 }
197
198 /// Returns a summary string for this ADL alert item.
199 pub fn to_summary_string(&self) -> String {
200 let (contract_triggered, contract_stopped, equity_triggered, equity_stopped) =
201 self.adl_status();
202
203 format!(
204 "{} {}: Balance={:.2}, PnL Ratio={:.4}%, Safety={:.2}%, ContractADL={}/{}, EquityADL={}/{}",
205 self.coin,
206 self.symbol,
207 self.balance,
208 self.pnl_ratio * 100.0,
209 self.safety_margin_percentage(),
210 if contract_triggered { "TRIGGERED" } else { "ok" },
211 if contract_stopped { "STOPPED" } else { "active" },
212 if equity_triggered { "TRIGGERED" } else { "ok" },
213 if equity_stopped { "STOPPED" } else { "active" }
214 )
215 }
216}
217
218/// Represents a WebSocket ADL alert update event.
219///
220/// Contains real-time ADL alert information for various trading pairs.
221/// Push frequency: 1 second for USDT Perpetual/Delivery, USDC Perpetual/Delivery, and Inverse Contracts.
222///
223/// # Bybit API Reference
224/// Topic: `adlAlert.{coin}` where coin can be:
225/// - `adlAlert.USDT` for USDT Perpetual/Delivery
226/// - `adlAlert.USDC` for USDC Perpetual/Delivery
227/// - `adlAlert.inverse` for Inverse contracts
228#[derive(Serialize, Deserialize, Debug, Clone)]
229#[serde(rename_all = "camelCase")]
230pub struct ADLAlertUpdate {
231 /// The WebSocket topic for the event (e.g., "adlAlert.USDT", "adlAlert.USDC", "adlAlert.inverse").
232 ///
233 /// Specifies the data stream for the ADL alert update.
234 /// Bots use this to determine which contract group the update belongs to.
235 #[serde(rename = "topic")]
236 pub topic: String,
237
238 /// The event type (e.g., "snapshot").
239 ///
240 /// ADL alert updates are typically snapshot type, containing the full current state.
241 #[serde(rename = "type")]
242 pub event_type: String,
243
244 /// The timestamp of the event in milliseconds.
245 ///
246 /// Indicates when the ADL alert update was generated by the system.
247 /// Bots use this to ensure data freshness and time-based analysis.
248 #[serde(rename = "ts")]
249 #[serde(with = "string_to_u64")]
250 pub timestamp: u64,
251
252 /// The ADL alert data.
253 ///
254 /// Contains a list of ADL alert items. Each item represents ADL alert information
255 /// for a specific trading pair.
256 #[serde(rename = "data")]
257 pub data: Vec<ADLAlertWebsocketItem>,
258}
259
260impl ADLAlertUpdate {
261 /// Returns the contract group from the topic.
262 ///
263 /// Extracts the contract group identifier from the WebSocket topic.
264 /// Examples:
265 /// - "adlAlert.USDT" -> "USDT"
266 /// - "adlAlert.USDC" -> "USDC"
267 /// - "adlAlert.inverse" -> "inverse"
268 pub fn contract_group(&self) -> Option<&str> {
269 self.topic.split('.').last()
270 }
271
272 /// Returns true if this is a snapshot update.
273 ///
274 /// Snapshot updates contain the full ADL alert state and should replace
275 /// the local state for the corresponding contract group.
276 pub fn is_snapshot(&self) -> bool {
277 self.event_type == "snapshot"
278 }
279
280 /// Returns the timestamp as a chrono DateTime.
281 pub fn timestamp_datetime(&self) -> chrono::DateTime<chrono::Utc> {
282 chrono::DateTime::from_timestamp((self.timestamp / 1000) as i64, 0)
283 .unwrap_or_else(chrono::Utc::now)
284 }
285
286 /// Returns the age of the update in milliseconds.
287 ///
288 /// Calculates how old this update is relative to the current time.
289 pub fn age_ms(&self) -> u64 {
290 let now = chrono::Utc::now().timestamp_millis() as u64;
291 if now > self.timestamp {
292 now - self.timestamp
293 } else {
294 0
295 }
296 }
297
298 /// Returns true if the update is stale (older than 2 seconds).
299 ///
300 /// Since ADL alert updates are pushed every 1 second, data older than 2 seconds
301 /// might be considered stale for real-time trading decisions.
302 pub fn is_stale(&self) -> bool {
303 self.age_ms() > 2000
304 }
305
306 /// Returns the number of ADL alert items in this update.
307 pub fn count(&self) -> usize {
308 self.data.len()
309 }
310
311 /// Returns true if there are no ADL alert items in this update.
312 pub fn is_empty(&self) -> bool {
313 self.data.is_empty()
314 }
315
316 /// Finds an ADL alert item for a specific symbol.
317 ///
318 /// Returns the first matching ADL alert item for the given symbol.
319 pub fn find_by_symbol(&self, symbol: &str) -> Option<&ADLAlertWebsocketItem> {
320 self.data.iter().find(|item| item.is_symbol(symbol))
321 }
322
323 /// Finds all ADL alert items for a specific coin.
324 pub fn filter_by_coin(&self, coin: &str) -> Vec<&ADLAlertWebsocketItem> {
325 self.data.iter().filter(|item| item.is_coin(coin)).collect()
326 }
327
328 /// Returns all ADL alert items where any ADL condition is triggered.
329 pub fn triggered_items(&self) -> Vec<&ADLAlertWebsocketItem> {
330 self.data
331 .iter()
332 .filter(|item| item.is_any_adl_triggered())
333 .collect()
334 }
335
336 /// Returns all ADL alert items where all ADL conditions are stopped.
337 pub fn stopped_items(&self) -> Vec<&ADLAlertWebsocketItem> {
338 self.data
339 .iter()
340 .filter(|item| item.is_all_adl_stopped())
341 .collect()
342 }
343
344 /// Returns the number of ADL alert items with triggered conditions.
345 pub fn count_triggered(&self) -> usize {
346 self.triggered_items().len()
347 }
348
349 /// Returns the number of ADL alert items with stopped conditions.
350 pub fn count_stopped(&self) -> usize {
351 self.stopped_items().len()
352 }
353
354 /// Returns the total balance across all ADL alert items.
355 pub fn total_balance(&self) -> f64 {
356 self.data.iter().map(|item| item.balance).sum()
357 }
358
359 /// Returns the average PnL ratio across all ADL alert items.
360 pub fn average_pnl_ratio(&self) -> Option<f64> {
361 if self.data.is_empty() {
362 None
363 } else {
364 Some(self.data.iter().map(|item| item.pnl_ratio).sum::<f64>() / self.data.len() as f64)
365 }
366 }
367
368 /// Returns the minimum balance among all ADL alert items.
369 pub fn min_balance(&self) -> Option<f64> {
370 self.data.iter().map(|item| item.balance).reduce(f64::min)
371 }
372
373 /// Returns the maximum balance among all ADL alert items.
374 pub fn max_balance(&self) -> Option<f64> {
375 self.data.iter().map(|item| item.balance).reduce(f64::max)
376 }
377
378 /// Returns the item with the lowest balance (most at risk).
379 pub fn most_at_risk_item(&self) -> Option<&ADLAlertWebsocketItem> {
380 self.data.iter().min_by(|a, b| {
381 a.balance
382 .partial_cmp(&b.balance)
383 .unwrap_or(std::cmp::Ordering::Equal)
384 })
385 }
386
387 /// Returns the item with the highest balance (least at risk).
388 pub fn least_at_risk_item(&self) -> Option<&ADLAlertWebsocketItem> {
389 self.data.iter().max_by(|a, b| {
390 a.balance
391 .partial_cmp(&b.balance)
392 .unwrap_or(std::cmp::Ordering::Equal)
393 })
394 }
395
396 /// Returns a summary string for this ADL alert update.
397 pub fn to_summary_string(&self) -> String {
398 format!(
399 "[{}] {}: {} items, {} triggered, {} stopped, Total Balance={:.2}, Avg PnL={:.4}%",
400 self.timestamp_datetime().format("%H:%M:%S"),
401 self.topic,
402 self.count(),
403 self.count_triggered(),
404 self.count_stopped(),
405 self.total_balance(),
406 self.average_pnl_ratio().unwrap_or(0.0) * 100.0
407 )
408 }
409
410 /// Validates the update for trading use.
411 ///
412 /// Returns `true` if:
413 /// 1. The update is not stale (≤ 2 seconds old)
414 /// 2. There is at least one ADL alert item
415 /// 3. The contract group can be extracted from the topic
416 pub fn is_valid_for_trading(&self) -> bool {
417 !self.is_stale() && !self.data.is_empty() && self.contract_group().is_some()
418 }
419
420 /// Returns the update latency in milliseconds.
421 ///
422 /// For comparing with other market data timestamps.
423 pub fn latency_ms(&self, other_timestamp: u64) -> i64 {
424 if self.timestamp > other_timestamp {
425 (self.timestamp - other_timestamp) as i64
426 } else {
427 (other_timestamp - self.timestamp) as i64
428 }
429 }
430
431 /// Returns all symbols that have ADL conditions triggered.
432 pub fn triggered_symbols(&self) -> Vec<String> {
433 self.triggered_items()
434 .iter()
435 .map(|item| item.symbol.clone())
436 .collect()
437 }
438
439 /// Returns all coins that have ADL conditions triggered.
440 pub fn triggered_coins(&self) -> Vec<String> {
441 let mut coins = std::collections::HashSet::new();
442 for item in self.triggered_items() {
443 coins.insert(item.coin.clone());
444 }
445 coins.into_iter().collect()
446 }
447}