1use 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#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
20#[non_exhaustive]
21pub struct LiquidityRule {
22 pub threshold: Threshold,
24 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 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}