Skip to main content

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}