Skip to main content

wickra_core/indicators/
liquidation_features.rs

1//! Liquidation Features — per-tick long/short liquidation breakdown.
2
3use crate::derivatives::DerivativesTick;
4use crate::traits::Indicator;
5
6/// The liquidation feature vector emitted by [`LiquidationFeatures`] for one
7/// tick.
8#[derive(Debug, Clone, Copy, PartialEq, Default)]
9pub struct LiquidationFeaturesOutput {
10    /// Long-side liquidation notional on this tick.
11    pub long: f64,
12    /// Short-side liquidation notional on this tick.
13    pub short: f64,
14    /// Net liquidation `long − short` (positive = longs being liquidated).
15    pub net: f64,
16    /// Total liquidation `long + short`.
17    pub total: f64,
18    /// Liquidation imbalance `(long − short) / (long + short)`, in `[−1, +1]`;
19    /// `0.0` when there is no liquidation.
20    pub imbalance: f64,
21}
22
23/// Liquidation Features — decomposes the long- and short-side liquidation
24/// notional carried by each tick into a small feature vector.
25///
26/// ```text
27/// net       = longLiquidation − shortLiquidation
28/// total     = longLiquidation + shortLiquidation
29/// imbalance = net / total                      (0 when total == 0)
30/// ```
31///
32/// Liquidation cascades are a perpetual-market-specific tail risk: a wave of
33/// long liquidations forces market sells that beget more liquidations. Splitting
34/// the flow into net, total and a bounded imbalance turns the raw venue feed
35/// into model-ready features — `total` sizes the stress, `imbalance` (and its
36/// sign) says which side is being flushed. A positive imbalance means longs are
37/// being liquidated (downside cascade), a negative one shorts (upside squeeze).
38///
39/// `Input = DerivativesTick`, `Output = LiquidationFeaturesOutput`. Stateless;
40/// ready after the first tick.
41///
42/// # Example
43///
44/// ```
45/// use wickra_core::{DerivativesTick, Indicator, LiquidationFeatures};
46///
47/// fn tick(long_liq: f64, short_liq: f64) -> DerivativesTick {
48///     DerivativesTick::new(
49///         0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, long_liq, short_liq, 0,
50///     )
51///     .unwrap()
52/// }
53///
54/// let mut liq = LiquidationFeatures::new();
55/// // 30 long vs 10 short liquidated: net 20, total 40, imbalance 0.5.
56/// let out = liq.update(tick(30.0, 10.0)).unwrap();
57/// assert_eq!(out.net, 20.0);
58/// assert_eq!(out.total, 40.0);
59/// assert_eq!(out.imbalance, 0.5);
60/// ```
61#[derive(Debug, Clone, Default)]
62pub struct LiquidationFeatures {
63    has_emitted: bool,
64}
65
66impl LiquidationFeatures {
67    /// Construct a new liquidation-features indicator.
68    #[must_use]
69    pub const fn new() -> Self {
70        Self { has_emitted: false }
71    }
72}
73
74impl Indicator for LiquidationFeatures {
75    type Input = DerivativesTick;
76    type Output = LiquidationFeaturesOutput;
77
78    #[inline]
79    fn update(&mut self, tick: DerivativesTick) -> Option<LiquidationFeaturesOutput> {
80        self.has_emitted = true;
81        let long = tick.long_liquidation;
82        let short = tick.short_liquidation;
83        let net = long - short;
84        let total = long + short;
85        let imbalance = if total == 0.0 { 0.0 } else { net / total };
86        Some(LiquidationFeaturesOutput {
87            long,
88            short,
89            net,
90            total,
91            imbalance,
92        })
93    }
94
95    fn reset(&mut self) {
96        self.has_emitted = false;
97    }
98
99    #[inline]
100    fn warmup_period(&self) -> usize {
101        1
102    }
103
104    #[inline]
105    fn is_ready(&self) -> bool {
106        self.has_emitted
107    }
108
109    #[inline]
110    fn name(&self) -> &'static str {
111        "LiquidationFeatures"
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::traits::BatchExt;
119
120    fn tick(long_liq: f64, short_liq: f64) -> DerivativesTick {
121        DerivativesTick::new_unchecked(
122            0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, long_liq, short_liq, 0,
123        )
124    }
125
126    #[test]
127    fn accessors_and_metadata() {
128        let liq = LiquidationFeatures::new();
129        assert_eq!(liq.name(), "LiquidationFeatures");
130        assert_eq!(liq.warmup_period(), 1);
131        assert!(!liq.is_ready());
132    }
133
134    #[test]
135    fn decomposes_liquidations() {
136        let mut liq = LiquidationFeatures::new();
137        let out = liq.update(tick(30.0, 10.0)).unwrap();
138        assert_eq!(out.long, 30.0);
139        assert_eq!(out.short, 10.0);
140        assert_eq!(out.net, 20.0);
141        assert_eq!(out.total, 40.0);
142        assert_eq!(out.imbalance, 0.5);
143        assert!(liq.is_ready());
144    }
145
146    #[test]
147    fn short_cascade_is_negative_imbalance() {
148        let mut liq = LiquidationFeatures::new();
149        let out = liq.update(tick(0.0, 50.0)).unwrap();
150        assert_eq!(out.net, -50.0);
151        assert_eq!(out.imbalance, -1.0);
152    }
153
154    #[test]
155    fn no_liquidation_is_zero_imbalance() {
156        let mut liq = LiquidationFeatures::new();
157        let out = liq.update(tick(0.0, 0.0)).unwrap();
158        assert_eq!(out.total, 0.0);
159        assert_eq!(out.imbalance, 0.0);
160    }
161
162    #[test]
163    fn batch_equals_streaming() {
164        let ticks: Vec<DerivativesTick> = (0..20)
165            .map(|i| tick(f64::from(i % 5) * 10.0, f64::from(i % 3) * 10.0))
166            .collect();
167        let mut a = LiquidationFeatures::new();
168        let mut b = LiquidationFeatures::new();
169        assert_eq!(
170            a.batch(&ticks),
171            ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
172        );
173    }
174
175    #[test]
176    fn reset_clears_state() {
177        let mut liq = LiquidationFeatures::new();
178        liq.update(tick(30.0, 10.0));
179        assert!(liq.is_ready());
180        liq.reset();
181        assert!(!liq.is_ready());
182    }
183}