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    fn update(&mut self, tick: DerivativesTick) -> Option<LiquidationFeaturesOutput> {
79        self.has_emitted = true;
80        let long = tick.long_liquidation;
81        let short = tick.short_liquidation;
82        let net = long - short;
83        let total = long + short;
84        let imbalance = if total == 0.0 { 0.0 } else { net / total };
85        Some(LiquidationFeaturesOutput {
86            long,
87            short,
88            net,
89            total,
90            imbalance,
91        })
92    }
93
94    fn reset(&mut self) {
95        self.has_emitted = false;
96    }
97
98    fn warmup_period(&self) -> usize {
99        1
100    }
101
102    fn is_ready(&self) -> bool {
103        self.has_emitted
104    }
105
106    fn name(&self) -> &'static str {
107        "LiquidationFeatures"
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::traits::BatchExt;
115
116    fn tick(long_liq: f64, short_liq: f64) -> DerivativesTick {
117        DerivativesTick::new_unchecked(
118            0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, long_liq, short_liq, 0,
119        )
120    }
121
122    #[test]
123    fn accessors_and_metadata() {
124        let liq = LiquidationFeatures::new();
125        assert_eq!(liq.name(), "LiquidationFeatures");
126        assert_eq!(liq.warmup_period(), 1);
127        assert!(!liq.is_ready());
128    }
129
130    #[test]
131    fn decomposes_liquidations() {
132        let mut liq = LiquidationFeatures::new();
133        let out = liq.update(tick(30.0, 10.0)).unwrap();
134        assert_eq!(out.long, 30.0);
135        assert_eq!(out.short, 10.0);
136        assert_eq!(out.net, 20.0);
137        assert_eq!(out.total, 40.0);
138        assert_eq!(out.imbalance, 0.5);
139        assert!(liq.is_ready());
140    }
141
142    #[test]
143    fn short_cascade_is_negative_imbalance() {
144        let mut liq = LiquidationFeatures::new();
145        let out = liq.update(tick(0.0, 50.0)).unwrap();
146        assert_eq!(out.net, -50.0);
147        assert_eq!(out.imbalance, -1.0);
148    }
149
150    #[test]
151    fn no_liquidation_is_zero_imbalance() {
152        let mut liq = LiquidationFeatures::new();
153        let out = liq.update(tick(0.0, 0.0)).unwrap();
154        assert_eq!(out.total, 0.0);
155        assert_eq!(out.imbalance, 0.0);
156    }
157
158    #[test]
159    fn batch_equals_streaming() {
160        let ticks: Vec<DerivativesTick> = (0..20)
161            .map(|i| tick(f64::from(i % 5) * 10.0, f64::from(i % 3) * 10.0))
162            .collect();
163        let mut a = LiquidationFeatures::new();
164        let mut b = LiquidationFeatures::new();
165        assert_eq!(
166            a.batch(&ticks),
167            ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
168        );
169    }
170
171    #[test]
172    fn reset_clears_state() {
173        let mut liq = LiquidationFeatures::new();
174        liq.update(tick(30.0, 10.0));
175        assert!(liq.is_ready());
176        liq.reset();
177        assert!(!liq.is_ready());
178    }
179}