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    #[inline]
95    fn update(&mut self, book: OrderBook) -> Option<f64> {
96        self.has_emitted = true;
97        let Some(mid) = book.mid() else {
98            return Some(0.0);
99        };
100        if book.bids.len() < 2 || book.asks.len() < 2 {
101            return Some(0.0);
102        }
103        let bid_slope = cumulative_slope(&book.bids, mid, -1.0);
104        let ask_slope = cumulative_slope(&book.asks, mid, 1.0);
105        Some(f64::midpoint(bid_slope, ask_slope))
106    }
107
108    fn reset(&mut self) {
109        self.has_emitted = false;
110    }
111
112    #[inline]
113    fn warmup_period(&self) -> usize {
114        1
115    }
116
117    #[inline]
118    fn is_ready(&self) -> bool {
119        self.has_emitted
120    }
121
122    #[inline]
123    fn name(&self) -> &'static str {
124        "DepthSlope"
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::traits::BatchExt;
132
133    fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
134        let to_levels = |xs: &[(f64, f64)]| {
135            xs.iter()
136                .map(|&(p, s)| Level::new(p, s).unwrap())
137                .collect::<Vec<_>>()
138        };
139        OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
140    }
141
142    #[test]
143    fn accessors_and_metadata() {
144        let ds = DepthSlope::new();
145        assert_eq!(ds.name(), "DepthSlope");
146        assert_eq!(ds.warmup_period(), 1);
147        assert!(!ds.is_ready());
148    }
149
150    #[test]
151    fn thickening_book_has_positive_slope() {
152        let mut ds = DepthSlope::new();
153        let out = ds
154            .update(book(
155                &[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)],
156                &[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)],
157            ))
158            .unwrap();
159        assert!(out > 0.0);
160        assert!(ds.is_ready());
161    }
162
163    #[test]
164    fn front_loaded_book_has_smaller_slope_than_back_loaded() {
165        // Same total depth (6 per side), but one book thickens away from the
166        // touch and the other thins. Cumulative slope is non-negative for both;
167        // the back-loaded book accumulates faster, so its slope is larger.
168        let mut back = DepthSlope::new();
169        let back_slope = back
170            .update(book(
171                &[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)],
172                &[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)],
173            ))
174            .unwrap();
175        let mut front = DepthSlope::new();
176        let front_slope = front
177            .update(book(
178                &[(99.0, 3.0), (98.0, 2.0), (97.0, 1.0)],
179                &[(101.0, 3.0), (102.0, 2.0), (103.0, 1.0)],
180            ))
181            .unwrap();
182        assert!(front_slope >= 0.0);
183        assert!(back_slope > front_slope);
184    }
185
186    #[test]
187    fn known_slope_value() {
188        // Symmetric book, each side: distances 1, 2; cumulative sizes 1, 3.
189        // OLS slope of (1->1, 2->3) = 2. Mean of two equal sides = 2.
190        let mut ds = DepthSlope::new();
191        let out = ds
192            .update(book(
193                &[(99.0, 1.0), (98.0, 2.0)],
194                &[(101.0, 1.0), (102.0, 2.0)],
195            ))
196            .unwrap();
197        assert!((out - 2.0).abs() < 1e-9);
198    }
199
200    #[test]
201    fn single_level_side_is_zero() {
202        let mut ds = DepthSlope::new();
203        // Bid side has only one level -> no slope -> 0.
204        assert_eq!(
205            ds.update(book(&[(100.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)])),
206            Some(0.0)
207        );
208    }
209
210    #[test]
211    fn empty_book_is_zero() {
212        let mut ds = DepthSlope::new();
213        assert_eq!(
214            ds.update(OrderBook::new_unchecked(vec![], vec![])),
215            Some(0.0)
216        );
217    }
218
219    #[test]
220    fn degenerate_distance_slope_is_zero() {
221        // Two levels at the same distance from mid carry zero x-variance.
222        let levels = [
223            Level::new_unchecked(100.0, 1.0),
224            Level::new_unchecked(100.0, 2.0),
225        ];
226        assert_eq!(cumulative_slope(&levels, 100.0, 1.0), 0.0);
227    }
228
229    #[test]
230    fn batch_equals_streaming() {
231        let books: Vec<OrderBook> = (0..20)
232            .map(|i| {
233                let extra = f64::from(i % 4);
234                book(
235                    &[(99.0, 1.0 + extra), (98.0, 2.0)],
236                    &[(101.0, 1.0), (102.0, 2.0 + extra)],
237                )
238            })
239            .collect();
240        let mut a = DepthSlope::new();
241        let mut b = DepthSlope::new();
242        assert_eq!(
243            a.batch(&books),
244            books
245                .iter()
246                .map(|x| b.update(x.clone()))
247                .collect::<Vec<_>>()
248        );
249    }
250
251    #[test]
252    fn reset_clears_state() {
253        let mut ds = DepthSlope::new();
254        ds.update(book(
255            &[(99.0, 1.0), (98.0, 2.0)],
256            &[(101.0, 1.0), (102.0, 2.0)],
257        ));
258        assert!(ds.is_ready());
259        ds.reset();
260        assert!(!ds.is_ready());
261    }
262}