1use super::types::{BookSide, OrderBookLevel, Trade, TradeSide};
19use serde::Serialize;
20
21pub const TRADE_SAMPLE: u32 = 500;
23
24pub const MIN_TRADES: usize = 30;
26
27pub const REPEATED_SIZE_SHARE: f64 = 0.30;
29
30pub const MIN_GAPS: usize = 20;
32
33pub const METRONOME_MAX_CV: f64 = 0.10;
36
37pub const PING_PONG_WINDOW_MS: u64 = 5_000;
39
40pub const PING_PONG_PRICE_TOLERANCE_PCT: f64 = 0.05;
42
43pub const PING_PONG_SHARE: f64 = 0.20;
46
47pub const MIN_PING_PONG_PAIRS: usize = 5;
49
50pub const LADDER_MIN_LEVELS: usize = 8;
52
53pub const LADDER_SIZE_TOLERANCE: f64 = 0.01;
55
56pub const LADDER_STEP_TOLERANCE: f64 = 0.05;
58
59pub const LADDER_DEPTH: usize = 25;
61
62const SIZE_DECIMALS: f64 = 1e8;
65
66#[derive(Debug, Clone, PartialEq, Serialize)]
68#[serde(tag = "kind", rename_all = "snake_case")]
69pub enum Finding {
70 RepeatedSize { size: f64, count: usize, of: usize },
72 Metronome {
76 mean_gap_ms: f64,
77 cv: f64,
78 gaps: usize,
79 dominant_size: Option<f64>,
80 },
81 PingPong { pairs: usize, share: f64 },
84 Ladder {
87 side: BookSide,
88 levels: usize,
89 size: f64,
90 step_pct: f64,
91 },
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize)]
96pub struct BotPatterns {
97 pub trades_sampled: usize,
99 pub tape_note: Option<String>,
101 pub findings: Vec<Finding>,
103 pub bot_like_pct: Option<f64>,
106 pub bot_like_volume_pct: Option<f64>,
109 pub book_checked: bool,
111}
112
113fn size_key(quantity: f64) -> i64 {
115 (quantity * SIZE_DECIMALS).round() as i64
116}
117
118fn dominant_size(trades: &[Trade]) -> Option<(f64, Vec<usize>)> {
120 let mut by_size: std::collections::HashMap<i64, Vec<usize>> = Default::default();
121 for (i, t) in trades.iter().enumerate() {
122 if t.quantity > 0.0 {
123 by_size.entry(size_key(t.quantity)).or_default().push(i);
124 }
125 }
126 by_size
127 .into_values()
128 .max_by(|a, b| a.len().cmp(&b.len()).then(b[0].cmp(&a[0])))
129 .map(|idx| (trades[idx[0]].quantity, idx))
130}
131
132pub fn repeated_size(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
135 if trades.len() < MIN_TRADES {
136 return None;
137 }
138 let (size, idx) = dominant_size(trades)?;
139 (idx.len() as f64 / trades.len() as f64 >= REPEATED_SIZE_SHARE).then_some((
140 Finding::RepeatedSize {
141 size,
142 count: idx.len(),
143 of: trades.len(),
144 },
145 idx,
146 ))
147}
148
149fn event_gaps(mut times: Vec<u64>) -> Vec<f64> {
152 times.sort_unstable();
153 times.dedup();
154 times.windows(2).map(|w| (w[1] - w[0]) as f64).collect()
155}
156
157fn mean_cv(gaps: &[f64]) -> Option<(f64, f64)> {
159 let n = gaps.len() as f64;
160 let mean = gaps.iter().sum::<f64>() / n;
161 if mean <= 0.0 {
162 return None;
163 }
164 let var = gaps.iter().map(|g| (g - mean).powi(2)).sum::<f64>() / n;
165 Some((mean, var.sqrt() / mean))
166}
167
168pub fn metronome(trades: &[Trade]) -> Option<Finding> {
171 if trades.len() < MIN_TRADES {
172 return None;
173 }
174 let judge = |times: Vec<u64>, dominant: Option<f64>| {
175 let gaps = event_gaps(times);
176 if gaps.len() < MIN_GAPS {
177 return None;
178 }
179 let (mean, cv) = mean_cv(&gaps)?;
180 (cv < METRONOME_MAX_CV).then_some(Finding::Metronome {
181 mean_gap_ms: mean,
182 cv,
183 gaps: gaps.len(),
184 dominant_size: dominant,
185 })
186 };
187 judge(trades.iter().map(|t| t.timestamp_ms).collect(), None).or_else(|| {
188 let (size, idx) = dominant_size(trades)?;
189 judge(
190 idx.iter().map(|&i| trades[i].timestamp_ms).collect(),
191 Some(size),
192 )
193 })
194}
195
196pub fn ping_pong(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
200 if trades.len() < MIN_TRADES {
201 return None;
202 }
203 let mut order: Vec<usize> = (0..trades.len()).collect();
204 order.sort_by_key(|&i| trades[i].timestamp_ms);
205 let mut paired = vec![false; trades.len()];
206 let mut members = Vec::new();
207 for (pos, &i) in order.iter().enumerate() {
208 if paired[i] {
209 continue;
210 }
211 let a = &trades[i];
212 for &j in &order[pos + 1..] {
213 let b = &trades[j];
214 if b.timestamp_ms - a.timestamp_ms > PING_PONG_WINDOW_MS {
215 break;
216 }
217 let opposite = matches!(
218 (&a.side, &b.side),
219 (TradeSide::Buy, TradeSide::Sell) | (TradeSide::Sell, TradeSide::Buy)
220 );
221 let near = a.price > 0.0
222 && ((b.price - a.price).abs() / a.price * 100.0) <= PING_PONG_PRICE_TOLERANCE_PCT;
223 if !paired[j] && opposite && near && size_key(a.quantity) == size_key(b.quantity) {
224 paired[i] = true;
225 paired[j] = true;
226 members.extend([i, j]);
227 break;
228 }
229 }
230 }
231 let pairs = members.len() / 2;
232 let share = members.len() as f64 / trades.len() as f64;
233 (pairs >= MIN_PING_PONG_PAIRS && share >= PING_PONG_SHARE)
234 .then_some((Finding::PingPong { pairs, share }, members))
235}
236
237fn ladder_side(levels: &[OrderBookLevel], side: BookSide) -> Option<Finding> {
240 let levels: Vec<&OrderBookLevel> = levels
241 .iter()
242 .filter(|l| l.price > 0.0 && l.quantity > 0.0)
243 .take(LADDER_DEPTH)
244 .collect();
245 let mut best: Option<(usize, usize)> = None; let mut start = 0;
247 while start + 1 < levels.len() {
248 let size = levels[start].quantity;
249 let step = (levels[start + 1].price - levels[start].price).abs();
250 let mut end = start + 1;
251 while end < levels.len() {
252 let same_size = (levels[end].quantity - size).abs() <= size * LADDER_SIZE_TOLERANCE;
253 let this_step = (levels[end].price - levels[end - 1].price).abs();
254 let same_step = step > 0.0 && (this_step - step).abs() <= step * LADDER_STEP_TOLERANCE;
255 if !(same_size && same_step) {
256 break;
257 }
258 end += 1;
259 }
260 let len = end - start;
261 if best.is_none_or(|(_, l)| len > l) {
262 best = Some((start, len));
263 }
264 start = end.max(start + 1);
265 }
266 let (s, len) = best?;
267 if len < LADDER_MIN_LEVELS {
268 return None;
269 }
270 let step = (levels[s + 1].price - levels[s].price).abs();
271 Some(Finding::Ladder {
272 side,
273 levels: len,
274 size: levels[s].quantity,
275 step_pct: step / levels[s].price * 100.0,
276 })
277}
278
279pub fn ladders(bids: &[OrderBookLevel], asks: &[OrderBookLevel]) -> Vec<Finding> {
281 [
282 ladder_side(bids, BookSide::Bid),
283 ladder_side(asks, BookSide::Ask),
284 ]
285 .into_iter()
286 .flatten()
287 .collect()
288}
289
290pub fn detect(
294 trades: Result<&[Trade], &str>,
295 book: Option<(&[OrderBookLevel], &[OrderBookLevel])>,
296) -> BotPatterns {
297 let mut findings = Vec::new();
298 let mut tape_note = None;
299 let mut bot_like_pct = None;
300 let mut bot_like_volume_pct = None;
301 let mut sampled = 0;
302 match trades {
303 Err(why) => tape_note = Some(why.to_string()),
304 Ok(t) if t.len() < MIN_TRADES => {
305 sampled = t.len();
306 tape_note = Some(format!(
307 "only {} recent trades; the tape signals need {MIN_TRADES}",
308 t.len()
309 ));
310 }
311 Ok(t) => {
312 sampled = t.len();
313 let mut covered = vec![false; t.len()];
314 if let Some((f, idx)) = repeated_size(t) {
315 idx.iter().for_each(|&i| covered[i] = true);
316 findings.push(f);
317 }
318 if let Some(f) = metronome(t) {
319 findings.push(f);
320 }
321 if let Some((f, idx)) = ping_pong(t) {
322 idx.iter().for_each(|&i| covered[i] = true);
323 findings.push(f);
324 }
325 let n = covered.iter().filter(|c| **c).count();
326 bot_like_pct = Some(n as f64 / t.len() as f64 * 100.0);
327 let value = |tr: &Trade| tr.quote_quantity.unwrap_or(tr.price * tr.quantity);
328 let total: f64 = t.iter().map(value).sum();
329 let bot: f64 = t
330 .iter()
331 .zip(&covered)
332 .filter(|(_, c)| **c)
333 .map(|(tr, _)| value(tr))
334 .sum();
335 bot_like_volume_pct = (total > 0.0).then(|| bot / total * 100.0);
336 }
337 }
338 if let Some((bids, asks)) = book {
339 findings.extend(ladders(bids, asks));
340 }
341 BotPatterns {
342 trades_sampled: sampled,
343 tape_note,
344 findings,
345 bot_like_pct,
346 bot_like_volume_pct,
347 book_checked: book.is_some(),
348 }
349}
350
351fn short(v: f64) -> String {
353 let s = format!("{:.6}", v);
354 let s = s.trim_end_matches('0').trim_end_matches('.');
355 if v.abs() >= 1000.0 {
356 format!("{v:.0}")
357 } else {
358 s.to_string()
359 }
360}
361
362impl Finding {
363 pub fn describe(&self) -> String {
365 match self {
366 Finding::RepeatedSize { size, count, of } => format!(
367 "{:.0}% of {of} trades are exactly {}",
368 *count as f64 / *of as f64 * 100.0,
369 short(*size)
370 ),
371 Finding::Metronome {
372 mean_gap_ms,
373 cv,
374 gaps,
375 dominant_size,
376 } => {
377 let whose = dominant_size
378 .map(|s| format!("size-{} trades", short(s)))
379 .unwrap_or_else(|| "trades".to_string());
380 format!(
381 "{whose} every {:.1}s ±{:.0}% over {gaps} gaps",
382 mean_gap_ms / 1000.0,
383 cv * 100.0
384 )
385 }
386 Finding::PingPong { pairs, share } => format!(
387 "{pairs} buy/sell ping-pong pairs ({:.0}% of trades)",
388 share * 100.0
389 ),
390 Finding::Ladder {
391 side,
392 levels,
393 size,
394 step_pct,
395 } => format!(
396 "{} ladder of {levels} levels × {} at {}% steps",
397 match side {
398 BookSide::Bid => "bid",
399 BookSide::Ask => "ask",
400 },
401 short(*size),
402 short(*step_pct)
403 ),
404 }
405 }
406}
407
408impl BotPatterns {
409 pub fn check(&self) -> super::types::HealthCheck {
412 use super::types::HealthCheck;
413 if !self.findings.is_empty() {
414 let evidence: Vec<String> = self.findings.iter().map(Finding::describe).collect();
415 let share = match (
416 self.bot_like_pct.filter(|p| *p > 0.0),
417 self.bot_like_volume_pct,
418 ) {
419 (Some(p), Some(v)) => {
420 format!("; {p:.0}% of sampled trades ({v:.1}% of their volume) look bot-made")
421 }
422 (Some(p), None) => format!("; {p:.0}% of sampled trades look bot-made"),
423 _ => String::new(),
424 };
425 return HealthCheck::Warn(format!(
426 "Bot patterns (likely market-making bots): {}{share}",
427 evidence.join("; ")
428 ));
429 }
430 let book = if self.book_checked {
431 " or the book"
432 } else {
433 ""
434 };
435 match &self.tape_note {
436 None => HealthCheck::Pass(format!(
437 "No bot patterns in {} recent trades{book}",
438 self.trades_sampled
439 )),
440 Some(why) if self.book_checked => HealthCheck::NotApplicable(format!(
441 "Bot patterns: no ladder in the book; trades not checked ({why})"
442 )),
443 Some(why) => HealthCheck::NotApplicable(format!("Bot patterns: not checked ({why})")),
444 }
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 fn trade(ms: u64, price: f64, qty: f64, side: TradeSide) -> Trade {
453 Trade {
454 price,
455 quantity: qty,
456 quote_quantity: None,
457 timestamp_ms: ms,
458 side,
459 id: None,
460 }
461 }
462
463 fn organic(n: usize) -> Vec<Trade> {
465 let mut ms = 1_000_000;
466 (0..n)
467 .map(|i| {
468 ms += 300 + (i as u64 * 7919) % 4_000;
469 let qty = 10.0 + ((i * 37) % 97) as f64 * 1.37;
470 let side = if (i * 13) % 3 == 0 {
471 TradeSide::Buy
472 } else {
473 TradeSide::Sell
474 };
475 trade(ms, 1.0 + ((i * 11) % 7) as f64 * 0.0001, qty, side)
476 })
477 .collect()
478 }
479
480 fn level(price: f64, quantity: f64) -> OrderBookLevel {
481 OrderBookLevel { price, quantity }
482 }
483
484 #[test]
485 fn test_an_organic_tape_and_book_show_nothing() {
486 let t = organic(200);
487 let bids: Vec<_> = (0..20)
488 .map(|i| {
489 level(
490 0.999 - i as f64 * 0.0001 * (1.0 + (i % 3) as f64),
491 100.0 + (i * 53 % 211) as f64,
492 )
493 })
494 .collect();
495 let p = detect(Ok(&t), Some((&bids, &[])));
496 assert!(p.findings.is_empty(), "{:?}", p.findings);
497 assert_eq!(p.bot_like_pct, Some(0.0));
498 assert!(matches!(
499 p.check(),
500 crate::market::types::HealthCheck::Pass(_)
501 ));
502 }
503
504 #[test]
505 fn test_one_size_in_a_third_of_the_tape_is_repeated_size() {
506 let mut t = organic(100);
507 for tr in t.iter_mut().step_by(3) {
508 tr.quantity = 1000.0;
509 }
510 let (f, idx) = repeated_size(&t).unwrap();
511 assert_eq!(idx.len(), 34);
512 assert!(matches!(f, Finding::RepeatedSize { size, count: 34, of: 100 } if size == 1000.0));
513 assert!(
514 f.describe().contains("34% of 100 trades are exactly 1000"),
515 "{}",
516 f.describe()
517 );
518 let mut t = organic(100);
520 for tr in t.iter_mut().step_by(4) {
521 tr.quantity = 1000.0;
522 }
523 assert!(repeated_size(&t).is_none());
524 }
525
526 #[test]
527 fn test_a_fixed_cadence_is_a_metronome_and_same_ms_fills_are_one_event() {
528 let mut t = Vec::new();
530 for i in 0..60u64 {
531 t.push(trade(i * 2_000, 1.0, 5.0 + i as f64, TradeSide::Buy));
532 if i % 5 == 0 {
533 t.push(trade(i * 2_000, 1.0, 7.0, TradeSide::Buy));
534 }
535 }
536 let Some(Finding::Metronome {
537 mean_gap_ms,
538 cv,
539 dominant_size: None,
540 ..
541 }) = metronome(&t)
542 else {
543 panic!("no metronome")
544 };
545 assert_eq!(mean_gap_ms, 2_000.0);
546 assert!(cv < 1e-9);
547 assert!(metronome(&organic(200)).is_none());
548 }
549
550 #[test]
551 fn test_a_bot_cadence_hidden_in_organic_trades_is_found_by_its_size() {
552 let mut t = organic(80);
554 let start = t[0].timestamp_ms;
555 for i in 0..45u64 {
556 t.push(trade(start + 7 + i * 3_000, 1.0, 250.0, TradeSide::Sell));
557 }
558 let Some(Finding::Metronome {
559 dominant_size: Some(s),
560 mean_gap_ms,
561 ..
562 }) = metronome(&t)
563 else {
564 panic!("no metronome: {:?}", metronome(&t))
565 };
566 assert_eq!(s, 250.0);
567 assert_eq!(mean_gap_ms, 3_000.0);
568 }
569
570 #[test]
571 fn test_buy_sell_pairs_of_one_size_are_ping_pong() {
572 let mut t = organic(60);
573 let start = t.last().unwrap().timestamp_ms + 10_000;
574 for i in 0..15u64 {
575 let ms = start + i * 20_000;
576 let qty = 100.0 + i as f64; t.push(trade(ms, 1.0, qty, TradeSide::Buy));
578 t.push(trade(ms + 800, 1.0002, qty, TradeSide::Sell));
579 }
580 let (f, idx) = ping_pong(&t).unwrap();
581 assert_eq!(idx.len(), 30);
582 assert!(matches!(f, Finding::PingPong { pairs: 15, .. }), "{f:?}");
583 let mut slow = organic(60);
585 for i in 0..15u64 {
586 let ms = 9_000_000 + i * 20_000;
587 slow.push(trade(ms, 1.0, 50.5, TradeSide::Buy));
588 slow.push(trade(
589 ms + PING_PONG_WINDOW_MS + 1,
590 1.0,
591 50.5,
592 TradeSide::Sell,
593 ));
594 }
595 assert!(ping_pong(&slow).is_none());
596 let mut far = organic(60);
597 for i in 0..15u64 {
598 let ms = 9_000_000 + i * 20_000;
599 far.push(trade(ms, 1.0, 50.5, TradeSide::Buy));
600 far.push(trade(ms + 100, 1.01, 50.5, TradeSide::Sell));
601 }
602 assert!(ping_pong(&far).is_none());
603 }
604
605 #[test]
606 fn test_many_dust_bot_trades_are_little_of_the_volume() {
607 let mut t: Vec<Trade> = (0..90)
609 .map(|i| trade(1_000 + i * 977, 100.0, 0.001, TradeSide::Buy))
610 .collect();
611 t.extend(
612 (0..10).map(|i| trade(500_000 + i * 3_331, 100.0, 50.0 + i as f64, TradeSide::Sell)),
613 );
614 let p = detect(Ok(&t), None);
615 assert_eq!(p.bot_like_pct, Some(90.0));
616 let v = p.bot_like_volume_pct.unwrap();
617 assert!(v < 0.05, "{v}");
618 let crate::market::types::HealthCheck::Warn(m) = p.check() else {
619 panic!()
620 };
621 assert!(
622 m.contains("90% of sampled trades (0.0% of their volume) look bot-made"),
623 "{m}"
624 );
625 }
626
627 #[test]
628 fn test_equal_levels_at_even_steps_are_a_ladder() {
629 let asks: Vec<_> = (0..12)
630 .map(|i| level(1.0001 + i as f64 * 0.0001, 5_000.0))
631 .collect();
632 let bids: Vec<_> = (0..12)
633 .map(|i| level(0.9999 - i as f64 * 0.0001, 100.0 + i as f64 * 9.0))
634 .collect();
635 let found = ladders(&bids, &asks);
636 assert_eq!(found.len(), 1);
637 let Finding::Ladder {
638 side,
639 levels,
640 size,
641 step_pct,
642 } = &found[0]
643 else {
644 panic!()
645 };
646 assert_eq!((*side, *levels, *size), (BookSide::Ask, 12, 5_000.0));
647 assert!((step_pct - 0.01).abs() < 0.001, "{step_pct}");
648 let short_run: Vec<_> = (0..7)
650 .map(|i| level(1.0 + i as f64 * 0.0001, 5_000.0))
651 .collect();
652 assert!(ladders(&[], &short_run).is_empty());
653 }
654
655 #[test]
656 fn test_findings_warn_with_their_evidence_and_the_bot_like_share() {
657 let mut t = organic(100);
658 for tr in t.iter_mut().step_by(2) {
659 tr.quantity = 1000.0;
660 }
661 let p = detect(Ok(&t), None);
662 let crate::market::types::HealthCheck::Warn(m) = p.check() else {
663 panic!("{:?}", p.check())
664 };
665 assert!(
666 m.starts_with(
667 "Bot patterns (likely market-making bots): 50% of 100 trades are exactly 1000"
668 ),
669 "{m}"
670 );
671 assert!(m.contains("50% of sampled trades ("), "{m}");
672 assert!(m.contains("% of their volume) look bot-made"), "{m}");
673 assert_eq!(p.bot_like_pct, Some(50.0));
674 }
675
676 #[test]
677 fn test_no_tape_or_a_short_tape_says_why_and_still_reads_the_book() {
678 let p = detect(Err("the venue gives no trade feed"), None);
679 assert_eq!(p.trades_sampled, 0);
680 let crate::market::types::HealthCheck::NotApplicable(m) = p.check() else {
681 panic!()
682 };
683 assert!(m.contains("the venue gives no trade feed"), "{m}");
684 let asks: Vec<_> = (0..10)
686 .map(|i| level(1.0 + i as f64 * 0.001, 42.0))
687 .collect();
688 let p = detect(Err("x"), Some((&[], &asks)));
689 assert!(matches!(
690 p.check(),
691 crate::market::types::HealthCheck::Warn(_)
692 ));
693 let t = organic(MIN_TRADES - 1);
695 let p = detect(Ok(&t), None);
696 assert!(p.tape_note.unwrap().contains("only 29 recent trades"));
697 assert!(p.bot_like_pct.is_none());
698 }
699
700 #[test]
701 fn test_each_tape_signal_alone_refuses_a_short_tape() {
702 let t: Vec<Trade> = (0..MIN_TRADES as u64 - 1)
706 .map(|i| {
707 let side = if i % 2 == 0 {
708 TradeSide::Buy
709 } else {
710 TradeSide::Sell
711 };
712 trade(i * 2_000, 1.0, 10.0, side)
713 })
714 .collect();
715 assert!(repeated_size(&t).is_none());
716 assert!(metronome(&t).is_none());
717 assert!(ping_pong(&t).is_none());
718 }
719
720 #[test]
721 fn test_a_bid_ladder_is_described_as_a_bid_ladder() {
722 let bids: Vec<_> = (0..9)
723 .map(|i| level(0.999 - i as f64 * 0.001, 250.0))
724 .collect();
725 let found = ladders(&bids, &[]);
726 assert_eq!(found.len(), 1);
727 let d = found[0].describe();
728 assert!(d.starts_with("bid ladder of 9 levels × 250 at "), "{d}");
729 }
730
731 #[test]
732 fn test_a_tape_with_no_volume_gives_the_trade_share_only() {
733 let t: Vec<Trade> = (0..40)
736 .map(|i| trade(1_000 + i * 977, 0.0, 3.0, TradeSide::Buy))
737 .collect();
738 let p = detect(Ok(&t), None);
739 assert_eq!(p.bot_like_pct, Some(100.0));
740 assert_eq!(p.bot_like_volume_pct, None);
741 let crate::market::types::HealthCheck::Warn(m) = p.check() else {
742 panic!("{:?}", p.check())
743 };
744 assert!(m.ends_with("; 100% of sampled trades look bot-made"), "{m}");
745 }
746}