Skip to main content

egui_charts/services/detectors/
liquidity.rs

1//! The lifetime of large resting levels and recently added individual orders.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use chrono::{DateTime, TimeDelta, Utc};
6
7use super::{
8    DetectionKind, Detector, Threshold,
9    depth::{self, Depth, Price},
10    stops::direction,
11};
12use crate::data::{OrderEvent, RestingOrder, meets_threshold};
13use crate::model::{OrderBook, Signal, SignalId, SignalShape, Tick};
14use crate::services::SignalEvent;
15
16/// Follows levels within ten ticks of their side's inside, and size added within five ticks.
17/// Automatic thresholds are four times the median positive displayed size within ten ticks
18/// on both sides at the current book update. An even count uses the mean of the middle pair.
19#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
20#[non_exhaustive]
21pub struct LiquidityRule {
22    /// Size that opens a tracked level and identifies a large individual order.
23    pub threshold: Threshold,
24    /// Size added in one update that reports fresh liquidity.
25    pub fresh_threshold: Threshold,
26}
27
28impl Default for LiquidityRule {
29    fn default() -> Self {
30        Self {
31            threshold: Threshold::Auto,
32            fresh_threshold: Threshold::Auto,
33        }
34    }
35}
36
37#[derive(Default)]
38pub(super) struct Liquidity {
39    levels: BTreeMap<Price, Level>,
40    orders: HashMap<u64, Order>,
41    next: u64,
42}
43
44struct Level {
45    number: u64,
46    from: DateTime<Utc>,
47    peak: f64,
48    size: f64,
49    executed: f64,
50    since_update: f64,
51    withdrawn: f64,
52    wall: bool,
53    tested: bool,
54    beyond: bool,
55    largest: Option<u64>,
56}
57
58struct Order {
59    from: DateTime<Utc>,
60    price: Price,
61    size: f64,
62    executed: f64,
63}
64
65impl Liquidity {
66    pub(super) fn clear(&mut self, out: &mut Vec<SignalEvent>) {
67        for level in self.levels.values() {
68            out.push(SignalEvent::Remove(SignalId(format!(
69                "liquidity-{}",
70                level.number
71            ))));
72        }
73        self.levels.clear();
74        self.orders.clear();
75    }
76
77    pub(super) fn forget(&mut self, id: &SignalId) {
78        if let Some(number) =
79            id.0.strip_prefix("liquidity-")
80                .and_then(|s| s.parse::<u64>().ok())
81        {
82            self.levels.retain(|_, level| level.number != number);
83        }
84    }
85
86    fn threshold(rule: Threshold, book: &Depth) -> Option<f64> {
87        match rule {
88            Threshold::Fixed(size) => Some(size),
89            Threshold::Auto => {
90                let mut sizes: Vec<_> = book
91                    .levels
92                    .iter()
93                    .filter(|(price, _)| book.near(**price, 10))
94                    .map(|(_, level)| level.size)
95                    .filter(|size| *size > 0.0)
96                    .collect();
97                if sizes.is_empty() {
98                    return None;
99                }
100                sizes.sort_unstable_by(f64::total_cmp);
101                let middle = sizes.len() / 2;
102                let median = if sizes.len().is_multiple_of(2) {
103                    sizes[middle - 1] / 2.0 + sizes[middle] / 2.0
104                } else {
105                    sizes[middle]
106                };
107                Some((median * 4.0).min(f64::MAX))
108            }
109        }
110    }
111
112    pub(super) fn trade(
113        &mut self,
114        fill: &Tick,
115        tick: f64,
116        enabled: bool,
117        book: &Depth,
118        out: &mut Vec<SignalEvent>,
119    ) {
120        let passive = depth::resting(fill, tick);
121        let traded = (fill.price / tick).round() as i64;
122        let mut swept = Vec::new();
123        for (&price, level) in &mut self.levels {
124            if fill.ts < level.from {
125                continue;
126            }
127            if !book.exact && passive == Some(price) {
128                level.executed = (level.executed + fill.size).min(f64::MAX);
129                level.since_update = (level.since_update + fill.size).min(f64::MAX);
130                level.tested |= meets_threshold(level.executed, level.peak * 0.1)
131                    && book.inside(price.0) == Some(price.1);
132            }
133            if depth::beyond(price, traded) {
134                level.beyond = true;
135            }
136            if enabled
137                && !meets_threshold(level.size, level.peak * 0.2)
138                && meets_threshold(level.executed, (level.peak - level.size) * 0.5)
139                && depth::beyond(price, traded)
140            {
141                swept.push(price);
142            }
143        }
144        for price in swept {
145            let level = self.levels.remove(&price).unwrap();
146            out.push(Self::zone(price, tick, &level, Some(fill.ts)));
147            out.push(Self::marker(
148                DetectionKind::LiquiditySweep,
149                price,
150                fill.ts,
151                tick,
152                &level,
153            ));
154        }
155    }
156
157    pub(super) fn update(
158        &mut self,
159        before: &Depth,
160        book: &Depth,
161        time: DateTime<Utc>,
162        tick: f64,
163        snapshot: bool,
164        changes: Option<&OrderBook>,
165        settings: &Detector<LiquidityRule>,
166        out: &mut Vec<SignalEvent>,
167    ) {
168        self.orders
169            .retain(|_, order| time >= order.from && time - order.from <= TimeDelta::seconds(2));
170        let threshold = Self::threshold(settings.rule.threshold, book);
171        let fresh = Self::threshold(settings.rule.fresh_threshold, book);
172        if !settings.enabled {
173            self.clear(out);
174            return;
175        }
176        let changed: Option<BTreeSet<_>> = changes.map(|changes| {
177            changes
178                .bids
179                .iter()
180                .map(|level| (true, level))
181                .chain(changes.asks.iter().map(|level| (false, level)))
182                .map(|(bid, level)| (bid, (level.price / tick).round() as i64))
183                .collect()
184        });
185        let mut pulled = Vec::new();
186        let mut swept = Vec::new();
187        for (&price, level) in &mut self.levels {
188            if time < level.from {
189                continue;
190            }
191            let size = book.size(price);
192            if !book.exact
193                && changed
194                    .as_ref()
195                    .is_none_or(|changed| changed.contains(&price))
196            {
197                level.withdrawn += (level.size - size - level.since_update).max(0.0);
198                level.since_update = 0.0;
199            }
200            level.size = size;
201            level.peak = level.peak.max(size);
202            level.tested &= meets_threshold(level.executed, level.peak * 0.1);
203            if let Some((id, _)) = book.levels.get(&price).and_then(|level| level.largest) {
204                level.largest = Some(id);
205            }
206            if !level.wall
207                && level.tested
208                && meets_threshold(level.executed, level.peak * 0.1)
209                && meets_threshold(size, level.peak * 0.5)
210                && book
211                    .inside(price.0)
212                    .is_some_and(|inside| inside.abs_diff(price.1) >= 3)
213            {
214                level.wall = true;
215                out.push(Self::marker(DetectionKind::Wall, price, time, tick, level));
216            }
217            if !meets_threshold(size, level.peak * 0.2) {
218                if level.beyond && meets_threshold(level.executed, (level.peak - size) * 0.5) {
219                    swept.push(price);
220                } else if meets_threshold(level.withdrawn, level.peak * 0.8) {
221                    pulled.push(price);
222                }
223            }
224        }
225        for price in swept {
226            let level = self.levels.remove(&price).unwrap();
227            out.push(Self::zone(price, tick, &level, Some(time)));
228            out.push(Self::marker(
229                DetectionKind::LiquiditySweep,
230                price,
231                time,
232                tick,
233                &level,
234            ));
235        }
236        for price in pulled {
237            let level = self.levels.remove(&price).unwrap();
238            out.push(Self::zone(price, tick, &level, Some(time)));
239            let mut marker =
240                Self::marker(DetectionKind::PulledLiquidity, price, time, tick, &level);
241            if let SignalEvent::Upsert(signal) = &mut marker
242                && out.iter().any(|event| matches!(event, SignalEvent::Upsert(other)
243                    if other.category == DetectionKind::Spoofing.category() && other.shape == signal.shape))
244            {
245                signal.label = None;
246            }
247            out.push(marker);
248        }
249        for (&price, shown) in &book.levels {
250            let added = (shown.size - before.size(price)).max(0.0);
251            if !snapshot
252                && book.near(price, 5)
253                && fresh.is_some_and(|threshold| meets_threshold(added, threshold))
254            {
255                self.next += 1;
256                let level = Level {
257                    number: self.next,
258                    from: time,
259                    peak: added,
260                    size: shown.size,
261                    executed: 0.0,
262                    since_update: 0.0,
263                    withdrawn: 0.0,
264                    wall: false,
265                    tested: false,
266                    beyond: false,
267                    largest: shown.largest.map(|(id, _)| id),
268                };
269                out.push(Self::marker(
270                    DetectionKind::FreshLiquidity,
271                    price,
272                    time,
273                    tick,
274                    &level,
275                ));
276            }
277            if book.near(price, 10)
278                && threshold.is_some_and(|threshold| meets_threshold(shown.size, threshold))
279                && !self.levels.contains_key(&price)
280            {
281                self.next += 1;
282                let level = Level {
283                    number: self.next,
284                    from: time,
285                    peak: shown.size,
286                    size: shown.size,
287                    executed: 0.0,
288                    since_update: 0.0,
289                    withdrawn: 0.0,
290                    wall: false,
291                    tested: false,
292                    beyond: false,
293                    largest: shown.largest.map(|(id, _)| id),
294                };
295                out.push(Self::zone(price, tick, &level, None));
296                self.levels.insert(price, level);
297            }
298        }
299    }
300
301    pub(super) fn order(
302        &mut self,
303        event: &OrderEvent,
304        before: Option<&RestingOrder>,
305        book: &Depth,
306        time: DateTime<Utc>,
307        tick: f64,
308        settings: &Detector<LiquidityRule>,
309        out: &mut Vec<SignalEvent>,
310    ) {
311        if !settings.enabled {
312            return;
313        }
314        match event {
315            OrderEvent::Add(order) => {
316                let price = depth::price(order.side, order.price, tick);
317                if book.near(price, 5)
318                    && Self::threshold(settings.rule.threshold, book)
319                        .is_some_and(|threshold| meets_threshold(order.size, threshold))
320                {
321                    self.orders.insert(
322                        order.id,
323                        Order {
324                            from: time,
325                            price,
326                            size: order.size,
327                            executed: 0.0,
328                        },
329                    );
330                }
331            }
332            OrderEvent::Execute { id, size, .. } => {
333                // Sequenced executions count even when the tape does not identify its aggressor.
334                if let Some(order) = before {
335                    let price = depth::price(order.side, order.price, tick);
336                    if let Some(level) = self.levels.get_mut(&price) {
337                        level.executed += size;
338                        level.tested |= meets_threshold(level.executed, level.peak * 0.1)
339                            && book.inside(price.0) == Some(price.1);
340                    }
341                }
342                if let Some(order) = self.orders.get_mut(id) {
343                    order.executed += size;
344                }
345            }
346            OrderEvent::Cancel { id } | OrderEvent::Modify { id, .. } => {
347                let Some(old) = before else {
348                    return;
349                };
350                let price = depth::price(old.side, old.price, tick);
351                let remaining = match event {
352                    OrderEvent::Modify {
353                        price: to, size, ..
354                    } if to.is_none_or(|to| depth::price(old.side, to, tick) == price) => {
355                        size.unwrap_or(old.size)
356                    }
357                    _ => 0.0,
358                };
359                let withdrawn = (old.size - remaining).max(0.0);
360                if let Some(level) = self.levels.get_mut(&price) {
361                    level.withdrawn += withdrawn;
362                }
363                if let Some(order) = self.orders.get(id)
364                    && !meets_threshold(remaining, order.size * 0.1)
365                {
366                    if time >= order.from
367                        && time - order.from <= TimeDelta::seconds(2)
368                        && !meets_threshold(order.executed, order.size * 0.1)
369                    {
370                        self.next += 1;
371                        let level = Level {
372                            number: self.next,
373                            from: order.from,
374                            peak: order.size,
375                            size: remaining,
376                            executed: order.executed,
377                            since_update: 0.0,
378                            withdrawn,
379                            wall: false,
380                            tested: false,
381                            beyond: false,
382                            largest: Some(*id),
383                        };
384                        out.push(Self::marker(
385                            DetectionKind::Spoofing,
386                            order.price,
387                            time,
388                            tick,
389                            &level,
390                        ));
391                    }
392                    self.orders.remove(id);
393                }
394            }
395        }
396    }
397
398    fn zone(price: Price, tick: f64, level: &Level, to: Option<DateTime<Utc>>) -> SignalEvent {
399        SignalEvent::Upsert(Signal {
400            id: SignalId(format!("liquidity-{}", level.number)),
401            shape: SignalShape::PriceZone {
402                from: level.from,
403                to,
404                price: price.1 as f64 * tick,
405                direction: direction(depth::side(price)),
406            },
407            category: DetectionKind::LargeLiquidity.category().into(),
408            label: None,
409            tooltip: Some(Self::tooltip(level)),
410        })
411    }
412
413    fn marker(
414        kind: DetectionKind,
415        price: Price,
416        time: DateTime<Utc>,
417        tick: f64,
418        level: &Level,
419    ) -> SignalEvent {
420        SignalEvent::Upsert(Signal {
421            id: SignalId(format!("{}-{}", kind.category(), level.number)),
422            shape: SignalShape::Marker {
423                time,
424                price: Some(price.1 as f64 * tick),
425                direction: direction(depth::side(price)),
426            },
427            category: kind.category().into(),
428            label: (!matches!(kind, DetectionKind::FreshLiquidity | DetectionKind::Wall))
429                .then(|| kind.category().into()),
430            tooltip: Some(format!("{}: {}", kind.category(), Self::tooltip(level))),
431        })
432    }
433
434    fn tooltip(level: &Level) -> String {
435        format!(
436            "Peak {}, executed {}, withdrawn {}{}",
437            level.peak,
438            level.executed,
439            level.withdrawn,
440            level
441                .largest
442                .map_or_else(String::new, |id| format!(", largest resting order {id}"))
443        )
444    }
445}