Skip to main content

wickra_core/indicators/
depth_slope.rs

1//! Depth Slope — how fast resting liquidity accumulates away from the mid.
2
3use crate::microstructure::{Level, OrderBook};
4use crate::traits::Indicator;
5
6/// Ordinary-least-squares slope of cumulative resting size against distance
7/// from the mid, over the levels of one book side.
8///
9/// `signed_distance` is `+1.0` for the ask side (price above the mid) and
10/// `−1.0` for the bid side (price below the mid), so the regressor `x` —
11/// distance from the mid — is non-negative on both sides. The response `y` is
12/// the cumulative size walking outward from the touch. Returns `0.0` for a
13/// degenerate fit where every level sits at the same distance (zero variance in
14/// `x`).
15fn cumulative_slope(levels: &[Level], mid: f64, signed_distance: f64) -> f64 {
16    let count = levels.len() as f64;
17    let mut cumulative = 0.0;
18    let mut sum_x = 0.0;
19    let mut sum_y = 0.0;
20    let mut sum_xy = 0.0;
21    let mut sum_xx = 0.0;
22    for level in levels {
23        let x = signed_distance * (level.price - mid);
24        cumulative += level.size;
25        sum_x += x;
26        sum_y += cumulative;
27        sum_xy += x * cumulative;
28        sum_xx += x * x;
29    }
30    let denom = count * sum_xx - sum_x * sum_x;
31    if denom == 0.0 {
32        return 0.0;
33    }
34    (count * sum_xy - sum_x * sum_y) / denom
35}
36
37/// Depth Slope — the average rate at which cumulative resting size grows with
38/// distance from the mid, across the bid and ask sides of the book.
39///
40/// For each side the indicator runs an ordinary-least-squares regression of
41/// cumulative size (walking outward from the touch) on the level's distance
42/// from the mid, then reports the mean of the two slopes:
43///
44/// ```text
45/// slope_side = OLS slope of (|priceᵢ − mid|, Σ_{j≤i} sizeⱼ)
46/// depthSlope = (slope_bid + slope_ask) / 2
47/// ```
48///
49/// Because the response is *cumulative* size it never decreases with distance,
50/// so the slope is non-negative: it is a magnitude, not a direction. A large
51/// slope means cumulative liquidity builds quickly away from the touch — a deep
52/// book that absorbs large orders with little walking; a small slope is a thin,
53/// shallow book. A book whose size is concentrated at the touch and thins out
54/// behind it (a fragile book) reads a *smaller* slope than one of equal total
55/// depth that thickens with distance.
56///
57/// A side with fewer than two levels carries no slope, so the indicator returns
58/// `0.0` whenever either side has fewer than two levels (including an empty
59/// book).
60///
61/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
62/// snapshot.
63///
64/// # Example
65///
66/// ```
67/// use wickra_core::{DepthSlope, Indicator, Level, OrderBook};
68///
69/// // Both sides thicken linearly away from the mid (sizes 1, 2, 3 …).
70/// let book = OrderBook::new(
71///     vec![Level::new(99.0, 1.0).unwrap(), Level::new(98.0, 2.0).unwrap()],
72///     vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 2.0).unwrap()],
73/// )
74/// .unwrap();
75/// let mut ds = DepthSlope::new();
76/// assert!(ds.update(book).unwrap() > 0.0);
77/// ```
78#[derive(Debug, Clone, Default)]
79pub struct DepthSlope {
80    has_emitted: bool,
81}
82
83impl DepthSlope {
84    /// Construct a new depth-slope indicator.
85    pub const fn new() -> Self {
86        Self { has_emitted: false }
87    }
88}
89
90impl Indicator for DepthSlope {
91    type Input = OrderBook;
92    type Output = f64;
93
94    fn update(&mut self, book: OrderBook) -> Option<f64> {
95        self.has_emitted = true;
96        let Some(mid) = book.mid() else {
97            return Some(0.0);
98        };
99        if book.bids.len() < 2 || book.asks.len() < 2 {
100            return Some(0.0);
101        }
102        let bid_slope = cumulative_slope(&book.bids, mid, -1.0);
103        let ask_slope = cumulative_slope(&book.asks, mid, 1.0);
104        Some(f64::midpoint(bid_slope, ask_slope))
105    }
106
107    fn reset(&mut self) {
108        self.has_emitted = false;
109    }
110
111    fn warmup_period(&self) -> usize {
112        1
113    }
114
115    fn is_ready(&self) -> bool {
116        self.has_emitted
117    }
118
119    fn name(&self) -> &'static str {
120        "DepthSlope"
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::traits::BatchExt;
128
129    fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
130        let to_levels = |xs: &[(f64, f64)]| {
131            xs.iter()
132                .map(|&(p, s)| Level::new(p, s).unwrap())
133                .collect::<Vec<_>>()
134        };
135        OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
136    }
137
138    #[test]
139    fn accessors_and_metadata() {
140        let ds = DepthSlope::new();
141        assert_eq!(ds.name(), "DepthSlope");
142        assert_eq!(ds.warmup_period(), 1);
143        assert!(!ds.is_ready());
144    }
145
146    #[test]
147    fn thickening_book_has_positive_slope() {
148        let mut ds = DepthSlope::new();
149        let out = ds
150            .update(book(
151                &[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)],
152                &[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)],
153            ))
154            .unwrap();
155        assert!(out > 0.0);
156        assert!(ds.is_ready());
157    }
158
159    #[test]
160    fn front_loaded_book_has_smaller_slope_than_back_loaded() {
161        // Same total depth (6 per side), but one book thickens away from the
162        // touch and the other thins. Cumulative slope is non-negative for both;
163        // the back-loaded book accumulates faster, so its slope is larger.
164        let mut back = DepthSlope::new();
165        let back_slope = back
166            .update(book(
167                &[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)],
168                &[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)],
169            ))
170            .unwrap();
171        let mut front = DepthSlope::new();
172        let front_slope = front
173            .update(book(
174                &[(99.0, 3.0), (98.0, 2.0), (97.0, 1.0)],
175                &[(101.0, 3.0), (102.0, 2.0), (103.0, 1.0)],
176            ))
177            .unwrap();
178        assert!(front_slope >= 0.0);
179        assert!(back_slope > front_slope);
180    }
181
182    #[test]
183    fn known_slope_value() {
184        // Symmetric book, each side: distances 1, 2; cumulative sizes 1, 3.
185        // OLS slope of (1->1, 2->3) = 2. Mean of two equal sides = 2.
186        let mut ds = DepthSlope::new();
187        let out = ds
188            .update(book(
189                &[(99.0, 1.0), (98.0, 2.0)],
190                &[(101.0, 1.0), (102.0, 2.0)],
191            ))
192            .unwrap();
193        assert!((out - 2.0).abs() < 1e-9);
194    }
195
196    #[test]
197    fn single_level_side_is_zero() {
198        let mut ds = DepthSlope::new();
199        // Bid side has only one level -> no slope -> 0.
200        assert_eq!(
201            ds.update(book(&[(100.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)])),
202            Some(0.0)
203        );
204    }
205
206    #[test]
207    fn empty_book_is_zero() {
208        let mut ds = DepthSlope::new();
209        assert_eq!(
210            ds.update(OrderBook::new_unchecked(vec![], vec![])),
211            Some(0.0)
212        );
213    }
214
215    #[test]
216    fn degenerate_distance_slope_is_zero() {
217        // Two levels at the same distance from mid carry zero x-variance.
218        let levels = [
219            Level::new_unchecked(100.0, 1.0),
220            Level::new_unchecked(100.0, 2.0),
221        ];
222        assert_eq!(cumulative_slope(&levels, 100.0, 1.0), 0.0);
223    }
224
225    #[test]
226    fn batch_equals_streaming() {
227        let books: Vec<OrderBook> = (0..20)
228            .map(|i| {
229                let extra = f64::from(i % 4);
230                book(
231                    &[(99.0, 1.0 + extra), (98.0, 2.0)],
232                    &[(101.0, 1.0), (102.0, 2.0 + extra)],
233                )
234            })
235            .collect();
236        let mut a = DepthSlope::new();
237        let mut b = DepthSlope::new();
238        assert_eq!(
239            a.batch(&books),
240            books
241                .iter()
242                .map(|x| b.update(x.clone()))
243                .collect::<Vec<_>>()
244        );
245    }
246
247    #[test]
248    fn reset_clears_state() {
249        let mut ds = DepthSlope::new();
250        ds.update(book(
251            &[(99.0, 1.0), (98.0, 2.0)],
252            &[(101.0, 1.0), (102.0, 2.0)],
253        ));
254        assert!(ds.is_ready());
255        ds.reset();
256        assert!(!ds.is_ready());
257    }
258}