1use super::types::{BookSide, OrderBookLevel, Trade, TradeSide};
22use serde::Serialize;
23
24pub const TRADE_SAMPLE: u32 = 500;
26
27pub const MIN_TRADES: usize = 30;
29
30pub const REPEATED_SIZE_SHARE: f64 = 0.30;
32
33pub const FEW_SIZES: usize = 3;
35
36pub const FEW_SIZES_SHARE: f64 = 0.50;
39
40pub const FEW_SIZES_MIN_SHARE: f64 = 0.05;
43
44pub const MIN_GAPS: usize = 20;
46
47pub const METRONOME_MAX_CV: f64 = 0.10;
50
51pub const CADENCE_TOLERANCE: f64 = 0.02;
53
54pub const CADENCE_SHARE: f64 = 0.30;
57
58pub const CADENCE_MIN_GAP_MS: f64 = 5_000.0;
61
62pub const PING_PONG_WINDOW_MS: u64 = 5_000;
64
65pub const PING_PONG_PRICE_TOLERANCE_PCT: f64 = 0.05;
67
68pub const PING_PONG_SHARE: f64 = 0.20;
71
72pub const MIN_PING_PONG_PAIRS: usize = 5;
74
75pub const LADDER_MIN_LEVELS: usize = 8;
77
78pub const LADDER_SIZE_TOLERANCE: f64 = 0.01;
80
81pub const LADDER_STEP_TOLERANCE: f64 = 0.05;
83
84pub const LADDER_DEPTH: usize = 25;
86
87const SIZE_DECIMALS: f64 = 1e8;
90
91#[derive(Debug, Clone, PartialEq, Serialize)]
93#[serde(tag = "kind", rename_all = "snake_case")]
94pub enum Finding {
95 RepeatedSize { size: f64, count: usize, of: usize },
97 FewSizes {
100 sizes: Vec<f64>,
101 count: usize,
102 of: usize,
103 },
104 Metronome {
108 mean_gap_ms: f64,
109 cv: f64,
110 gaps: usize,
111 dominant_size: Option<f64>,
112 },
113 Cadence {
116 gap_ms: f64,
117 share: f64,
118 gaps: usize,
119 },
120 PingPong { pairs: usize, share: f64 },
123 Ladder {
127 side: BookSide,
128 levels: usize,
129 shape: LadderShape,
130 size: f64,
131 last_size: f64,
132 step_pct: f64,
133 },
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
138#[serde(rename_all = "snake_case")]
139pub enum LadderShape {
140 Even,
142 Falling,
144 Rising,
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize)]
150pub struct BotPatterns {
151 pub trades_sampled: usize,
153 pub tape_note: Option<String>,
155 pub findings: Vec<Finding>,
157 pub bot_like_pct: Option<f64>,
160 pub bot_like_volume_pct: Option<f64>,
163 pub book_checked: bool,
165}
166
167fn size_key(quantity: f64) -> i64 {
169 (quantity * SIZE_DECIMALS).round() as i64
170}
171
172fn sizes_by_count(trades: &[Trade]) -> Vec<(f64, Vec<usize>)> {
174 let mut by_size: std::collections::HashMap<i64, Vec<usize>> = Default::default();
175 for (i, t) in trades.iter().enumerate() {
176 if t.quantity > 0.0 {
177 by_size.entry(size_key(t.quantity)).or_default().push(i);
178 }
179 }
180 let mut sizes: Vec<Vec<usize>> = by_size.into_values().collect();
181 sizes.sort_by(|a, b| b.len().cmp(&a.len()).then(a[0].cmp(&b[0])));
184 sizes
185 .into_iter()
186 .map(|idx| (trades[idx[0]].quantity, idx))
187 .collect()
188}
189
190fn dominant_size(trades: &[Trade]) -> Option<(f64, Vec<usize>)> {
192 sizes_by_count(trades).into_iter().next()
193}
194
195pub fn repeated_size(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
198 if trades.len() < MIN_TRADES {
199 return None;
200 }
201 let (size, idx) = dominant_size(trades)?;
202 (idx.len() as f64 / trades.len() as f64 >= REPEATED_SIZE_SHARE).then_some((
203 Finding::RepeatedSize {
204 size,
205 count: idx.len(),
206 of: trades.len(),
207 },
208 idx,
209 ))
210}
211
212pub fn few_sizes(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
216 if trades.len() < MIN_TRADES {
217 return None;
218 }
219 let n = trades.len() as f64;
220 let top: Vec<(f64, Vec<usize>)> = sizes_by_count(trades)
221 .into_iter()
222 .take(FEW_SIZES)
223 .filter(|(_, idx)| idx.len() as f64 / n >= FEW_SIZES_MIN_SHARE)
224 .collect();
225 let idx: Vec<usize> = top
226 .iter()
227 .flat_map(|(_, idx)| idx.iter().copied())
228 .collect();
229 (top.len() > 1 && idx.len() as f64 / n >= FEW_SIZES_SHARE).then(|| {
230 (
231 Finding::FewSizes {
232 sizes: top.iter().map(|(s, _)| *s).collect(),
233 count: idx.len(),
234 of: trades.len(),
235 },
236 idx,
237 )
238 })
239}
240
241fn event_gaps(mut times: Vec<u64>) -> Vec<f64> {
244 times.sort_unstable();
245 times.dedup();
246 times.windows(2).map(|w| (w[1] - w[0]) as f64).collect()
247}
248
249fn mean_cv(gaps: &[f64]) -> Option<(f64, f64)> {
251 let n = gaps.len() as f64;
252 let mean = gaps.iter().sum::<f64>() / n;
253 if mean <= 0.0 {
254 return None;
255 }
256 let var = gaps.iter().map(|g| (g - mean).powi(2)).sum::<f64>() / n;
257 Some((mean, var.sqrt() / mean))
258}
259
260pub fn metronome(trades: &[Trade]) -> Option<Finding> {
263 if trades.len() < MIN_TRADES {
264 return None;
265 }
266 let judge = |times: Vec<u64>, dominant: Option<f64>| {
267 let gaps = event_gaps(times);
268 if gaps.len() < MIN_GAPS {
269 return None;
270 }
271 let (mean, cv) = mean_cv(&gaps)?;
272 (cv < METRONOME_MAX_CV).then_some(Finding::Metronome {
273 mean_gap_ms: mean,
274 cv,
275 gaps: gaps.len(),
276 dominant_size: dominant,
277 })
278 };
279 judge(trades.iter().map(|t| t.timestamp_ms).collect(), None).or_else(|| {
280 let (size, idx) = dominant_size(trades)?;
281 judge(
282 idx.iter().map(|&i| trades[i].timestamp_ms).collect(),
283 Some(size),
284 )
285 })
286}
287
288pub fn cadence(trades: &[Trade]) -> Option<Finding> {
292 if trades.len() < MIN_TRADES {
293 return None;
294 }
295 let mut gaps = event_gaps(trades.iter().map(|t| t.timestamp_ms).collect());
296 if gaps.len() < MIN_GAPS {
297 return None;
298 }
299 gaps.sort_by(f64::total_cmp);
300 let (mut lo, mut hi) = (0, 0);
303 let mut best: Option<(usize, usize)> = None; for (i, &g) in gaps.iter().enumerate() {
305 if g < CADENCE_MIN_GAP_MS {
306 continue;
307 }
308 while gaps[lo] < g * (1.0 - CADENCE_TOLERANCE) {
309 lo += 1;
310 }
311 hi = hi.max(i);
312 while hi + 1 < gaps.len() && gaps[hi + 1] <= g * (1.0 + CADENCE_TOLERANCE) {
313 hi += 1;
314 }
315 let count = hi + 1 - lo;
316 if best.is_none_or(|(c, _)| count > c) {
317 best = Some((count, i));
318 }
319 }
320 let (count, i) = best?;
321 let share = count as f64 / gaps.len() as f64;
322 (share >= CADENCE_SHARE).then_some(Finding::Cadence {
323 gap_ms: gaps[i],
324 share,
325 gaps: gaps.len(),
326 })
327}
328
329pub fn ping_pong(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
333 if trades.len() < MIN_TRADES {
334 return None;
335 }
336 let mut order: Vec<usize> = (0..trades.len()).collect();
337 order.sort_by_key(|&i| trades[i].timestamp_ms);
338 let mut paired = vec![false; trades.len()];
339 let mut members = Vec::new();
340 for (pos, &i) in order.iter().enumerate() {
341 if paired[i] {
342 continue;
343 }
344 let a = &trades[i];
345 for &j in &order[pos + 1..] {
346 let b = &trades[j];
347 if b.timestamp_ms - a.timestamp_ms > PING_PONG_WINDOW_MS {
348 break;
349 }
350 let opposite = matches!(
351 (&a.side, &b.side),
352 (TradeSide::Buy, TradeSide::Sell) | (TradeSide::Sell, TradeSide::Buy)
353 );
354 let near = a.price > 0.0
355 && ((b.price - a.price).abs() / a.price * 100.0) <= PING_PONG_PRICE_TOLERANCE_PCT;
356 if !paired[j] && opposite && near && size_key(a.quantity) == size_key(b.quantity) {
357 paired[i] = true;
358 paired[j] = true;
359 members.extend([i, j]);
360 break;
361 }
362 }
363 }
364 let pairs = members.len() / 2;
365 let share = members.len() as f64 / trades.len() as f64;
366 (pairs >= MIN_PING_PONG_PAIRS && share >= PING_PONG_SHARE)
367 .then_some((Finding::PingPong { pairs, share }, members))
368}
369
370fn size_direction(a: f64, b: f64) -> i8 {
373 if (b - a).abs() <= a * LADDER_SIZE_TOLERANCE {
374 0
375 } else if b > a {
376 1
377 } else {
378 -1
379 }
380}
381
382fn ladder_side(levels: &[OrderBookLevel], side: BookSide) -> Option<Finding> {
385 let levels: Vec<&OrderBookLevel> = levels
386 .iter()
387 .filter(|l| l.price > 0.0 && l.quantity > 0.0)
388 .take(LADDER_DEPTH)
389 .collect();
390 let mut best: Option<(usize, usize, i8)> = None; let mut start = 0;
392 while start + 1 < levels.len() {
393 let step = (levels[start + 1].price - levels[start].price).abs();
394 let mut dir = 0;
395 let mut end = start + 1;
396 while end < levels.len() {
397 let this_step = (levels[end].price - levels[end - 1].price).abs();
398 let same_step = step > 0.0 && (this_step - step).abs() <= step * LADDER_STEP_TOLERANCE;
399 let d = size_direction(levels[end - 1].quantity, levels[end].quantity);
400 if !same_step || (d != 0 && dir != 0 && d != dir) {
401 break;
402 }
403 if d != 0 {
404 dir = d;
405 }
406 end += 1;
407 }
408 let len = end - start;
409 if best.is_none_or(|(_, l, _)| len > l) {
410 best = Some((start, len, dir));
411 }
412 start = (end - 1).max(start + 1);
415 }
416 let (s, len, dir) = best?;
417 if len < LADDER_MIN_LEVELS {
418 return None;
419 }
420 let (first, last) = (levels[s].quantity, levels[s + len - 1].quantity);
421 let even = levels[s..s + len]
422 .iter()
423 .all(|l| size_direction(first, l.quantity) == 0);
424 let shape = match dir {
425 _ if even => LadderShape::Even,
426 1 => LadderShape::Rising,
427 _ => LadderShape::Falling,
428 };
429 let step = (levels[s + 1].price - levels[s].price).abs();
430 Some(Finding::Ladder {
431 side,
432 levels: len,
433 shape,
434 size: first,
435 last_size: last,
436 step_pct: step / levels[s].price * 100.0,
437 })
438}
439
440pub fn ladders(bids: &[OrderBookLevel], asks: &[OrderBookLevel]) -> Vec<Finding> {
442 [
443 ladder_side(bids, BookSide::Bid),
444 ladder_side(asks, BookSide::Ask),
445 ]
446 .into_iter()
447 .flatten()
448 .collect()
449}
450
451pub fn detect(
455 trades: Result<&[Trade], &str>,
456 book: Option<(&[OrderBookLevel], &[OrderBookLevel])>,
457) -> BotPatterns {
458 let mut findings = Vec::new();
459 let mut tape_note = None;
460 let mut bot_like_pct = None;
461 let mut bot_like_volume_pct = None;
462 let mut sampled = 0;
463 match trades {
464 Err(why) => tape_note = Some(why.to_string()),
465 Ok(t) if t.len() < MIN_TRADES => {
466 sampled = t.len();
467 tape_note = Some(format!(
468 "only {} recent trades; the tape signals need {MIN_TRADES}",
469 t.len()
470 ));
471 }
472 Ok(t) => {
473 sampled = t.len();
474 let mut covered = vec![false; t.len()];
475 if let Some((f, idx)) = repeated_size(t).or_else(|| few_sizes(t)) {
477 idx.iter().for_each(|&i| covered[i] = true);
478 findings.push(f);
479 }
480 if let Some(f) = metronome(t).or_else(|| cadence(t)) {
482 findings.push(f);
483 }
484 if let Some((f, idx)) = ping_pong(t) {
485 idx.iter().for_each(|&i| covered[i] = true);
486 findings.push(f);
487 }
488 let n = covered.iter().filter(|c| **c).count();
489 bot_like_pct = Some(n as f64 / t.len() as f64 * 100.0);
490 let value = |tr: &Trade| tr.quote_quantity.unwrap_or(tr.price * tr.quantity);
491 let total: f64 = t.iter().map(value).sum();
492 let bot: f64 = t
493 .iter()
494 .zip(&covered)
495 .filter(|(_, c)| **c)
496 .map(|(tr, _)| value(tr))
497 .sum();
498 bot_like_volume_pct = (total > 0.0).then(|| bot / total * 100.0);
499 }
500 }
501 if let Some((bids, asks)) = book {
502 findings.extend(ladders(bids, asks));
503 }
504 BotPatterns {
505 trades_sampled: sampled,
506 tape_note,
507 findings,
508 bot_like_pct,
509 bot_like_volume_pct,
510 book_checked: book.is_some(),
511 }
512}
513
514fn short(v: f64) -> String {
516 let s = format!("{:.6}", v);
517 let s = s.trim_end_matches('0').trim_end_matches('.');
518 if v.abs() >= 1000.0 {
519 format!("{v:.0}")
520 } else {
521 s.to_string()
522 }
523}
524
525fn step(pct: f64) -> String {
528 if pct <= 0.0 {
529 return short(pct);
530 }
531 let decimals = (1 - pct.log10().floor() as i32).max(0) as usize;
532 let s = format!("{pct:.decimals$}");
533 if s.contains('.') {
534 s.trim_end_matches('0').trim_end_matches('.').to_string()
535 } else {
536 s
537 }
538}
539
540impl Finding {
541 pub fn describe(&self) -> String {
543 match self {
544 Finding::RepeatedSize { size, count, of } => format!(
545 "{:.0}% of {of} trades are exactly {}",
546 *count as f64 / *of as f64 * 100.0,
547 short(*size)
548 ),
549 Finding::Metronome {
550 mean_gap_ms,
551 cv,
552 gaps,
553 dominant_size,
554 } => {
555 let whose = dominant_size
556 .map(|s| format!("size-{} trades", short(s)))
557 .unwrap_or_else(|| "trades".to_string());
558 format!(
559 "{whose} every {:.1}s ±{:.0}% over {gaps} gaps",
560 mean_gap_ms / 1000.0,
561 cv * 100.0
562 )
563 }
564 Finding::FewSizes { sizes, count, of } => format!(
565 "{:.0}% of {of} trades are one of {} sizes: {}",
566 *count as f64 / *of as f64 * 100.0,
567 sizes.len(),
568 sizes
569 .iter()
570 .map(|s| short(*s))
571 .collect::<Vec<_>>()
572 .join(", ")
573 ),
574 Finding::Cadence {
575 gap_ms,
576 share,
577 gaps,
578 } => format!(
579 "{:.0}% of {gaps} gaps between trades are {:.1}s ±{:.0}%",
580 share * 100.0,
581 gap_ms / 1000.0,
582 CADENCE_TOLERANCE * 100.0
583 ),
584 Finding::PingPong { pairs, share } => format!(
585 "{pairs} buy/sell ping-pong pairs ({:.0}% of trades)",
586 share * 100.0
587 ),
588 Finding::Ladder {
589 side,
590 levels,
591 shape,
592 size,
593 last_size,
594 step_pct,
595 } => {
596 let side = match side {
597 BookSide::Bid => "bid",
598 BookSide::Ask => "ask",
599 };
600 let sizes = match shape {
601 LadderShape::Even => format!("× {}", short(*size)),
602 LadderShape::Falling => {
603 format!("falling {} → {}", short(*size), short(*last_size))
604 }
605 LadderShape::Rising => {
606 format!("rising {} → {}", short(*size), short(*last_size))
607 }
608 };
609 format!(
610 "{side} ladder of {levels} levels {sizes} at {}% steps",
611 step(*step_pct)
612 )
613 }
614 }
615 }
616}
617
618impl BotPatterns {
619 pub fn check(&self) -> super::types::HealthCheck {
622 use super::types::HealthCheck;
623 if !self.findings.is_empty() {
624 let evidence: Vec<String> = self.findings.iter().map(Finding::describe).collect();
625 let share = match (
626 self.bot_like_pct.filter(|p| *p > 0.0),
627 self.bot_like_volume_pct,
628 ) {
629 (Some(p), Some(v)) => {
630 format!("; {p:.0}% of sampled trades ({v:.1}% of their volume) look bot-made")
631 }
632 (Some(p), None) => format!("; {p:.0}% of sampled trades look bot-made"),
633 _ => String::new(),
634 };
635 return HealthCheck::Warn(format!(
636 "Bot patterns (likely market-making bots): {}{share}",
637 evidence.join("; ")
638 ));
639 }
640 let book = if self.book_checked {
641 " or the book"
642 } else {
643 ""
644 };
645 match &self.tape_note {
646 None => HealthCheck::Pass(format!(
647 "No bot patterns in {} recent trades{book}",
648 self.trades_sampled
649 )),
650 Some(why) if self.book_checked => HealthCheck::NotApplicable(format!(
651 "Bot patterns: no ladder in the book; trades not checked ({why})"
652 )),
653 Some(why) => HealthCheck::NotApplicable(format!("Bot patterns: not checked ({why})")),
654 }
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 fn trade(ms: u64, price: f64, qty: f64, side: TradeSide) -> Trade {
663 Trade {
664 price,
665 quantity: qty,
666 quote_quantity: None,
667 timestamp_ms: ms,
668 side,
669 id: None,
670 }
671 }
672
673 fn organic(n: usize) -> Vec<Trade> {
675 let mut ms = 1_000_000;
676 (0..n)
677 .map(|i| {
678 ms += 300 + (i as u64 * 7919) % 4_000;
679 let qty = 10.0 + ((i * 37) % 97) as f64 * 1.37;
680 let side = if (i * 13) % 3 == 0 {
681 TradeSide::Buy
682 } else {
683 TradeSide::Sell
684 };
685 trade(ms, 1.0 + ((i * 11) % 7) as f64 * 0.0001, qty, side)
686 })
687 .collect()
688 }
689
690 fn level(price: f64, quantity: f64) -> OrderBookLevel {
691 OrderBookLevel { price, quantity }
692 }
693
694 #[test]
695 fn test_an_organic_tape_and_book_show_nothing() {
696 let t = organic(200);
697 let bids: Vec<_> = (0..20)
698 .map(|i| {
699 level(
700 0.999 - i as f64 * 0.0001 * (1.0 + (i % 3) as f64),
701 100.0 + (i * 53 % 211) as f64,
702 )
703 })
704 .collect();
705 let p = detect(Ok(&t), Some((&bids, &[])));
706 assert!(p.findings.is_empty(), "{:?}", p.findings);
707 assert_eq!(p.bot_like_pct, Some(0.0));
708 assert!(matches!(
709 p.check(),
710 crate::market::types::HealthCheck::Pass(_)
711 ));
712 }
713
714 #[test]
715 fn test_one_size_in_a_third_of_the_tape_is_repeated_size() {
716 let mut t = organic(100);
717 for tr in t.iter_mut().step_by(3) {
718 tr.quantity = 1000.0;
719 }
720 let (f, idx) = repeated_size(&t).unwrap();
721 assert_eq!(idx.len(), 34);
722 assert!(matches!(f, Finding::RepeatedSize { size, count: 34, of: 100 } if size == 1000.0));
723 assert!(
724 f.describe().contains("34% of 100 trades are exactly 1000"),
725 "{}",
726 f.describe()
727 );
728 let mut t = organic(100);
730 for tr in t.iter_mut().step_by(4) {
731 tr.quantity = 1000.0;
732 }
733 assert!(repeated_size(&t).is_none());
734 }
735
736 #[test]
737 fn test_a_fixed_cadence_is_a_metronome_and_same_ms_fills_are_one_event() {
738 let mut t = Vec::new();
740 for i in 0..60u64 {
741 t.push(trade(i * 2_000, 1.0, 5.0 + i as f64, TradeSide::Buy));
742 if i % 5 == 0 {
743 t.push(trade(i * 2_000, 1.0, 7.0, TradeSide::Buy));
744 }
745 }
746 let Some(Finding::Metronome {
747 mean_gap_ms,
748 cv,
749 dominant_size: None,
750 ..
751 }) = metronome(&t)
752 else {
753 panic!("no metronome")
754 };
755 assert_eq!(mean_gap_ms, 2_000.0);
756 assert!(cv < 1e-9);
757 assert!(metronome(&organic(200)).is_none());
758 }
759
760 #[test]
761 fn test_a_bot_cadence_hidden_in_organic_trades_is_found_by_its_size() {
762 let mut t = organic(80);
764 let start = t[0].timestamp_ms;
765 for i in 0..45u64 {
766 t.push(trade(start + 7 + i * 3_000, 1.0, 250.0, TradeSide::Sell));
767 }
768 let Some(Finding::Metronome {
769 dominant_size: Some(s),
770 mean_gap_ms,
771 ..
772 }) = metronome(&t)
773 else {
774 panic!("no metronome: {:?}", metronome(&t))
775 };
776 assert_eq!(s, 250.0);
777 assert_eq!(mean_gap_ms, 3_000.0);
778 }
779
780 #[test]
781 fn test_buy_sell_pairs_of_one_size_are_ping_pong() {
782 let mut t = organic(60);
783 let start = t.last().unwrap().timestamp_ms + 10_000;
784 for i in 0..15u64 {
785 let ms = start + i * 20_000;
786 let qty = 100.0 + i as f64; t.push(trade(ms, 1.0, qty, TradeSide::Buy));
788 t.push(trade(ms + 800, 1.0002, qty, TradeSide::Sell));
789 }
790 let (f, idx) = ping_pong(&t).unwrap();
791 assert_eq!(idx.len(), 30);
792 assert!(matches!(f, Finding::PingPong { pairs: 15, .. }), "{f:?}");
793 let mut slow = organic(60);
795 for i in 0..15u64 {
796 let ms = 9_000_000 + i * 20_000;
797 slow.push(trade(ms, 1.0, 50.5, TradeSide::Buy));
798 slow.push(trade(
799 ms + PING_PONG_WINDOW_MS + 1,
800 1.0,
801 50.5,
802 TradeSide::Sell,
803 ));
804 }
805 assert!(ping_pong(&slow).is_none());
806 let mut far = organic(60);
807 for i in 0..15u64 {
808 let ms = 9_000_000 + i * 20_000;
809 far.push(trade(ms, 1.0, 50.5, TradeSide::Buy));
810 far.push(trade(ms + 100, 1.01, 50.5, TradeSide::Sell));
811 }
812 assert!(ping_pong(&far).is_none());
813 }
814
815 #[test]
816 fn test_many_dust_bot_trades_are_little_of_the_volume() {
817 let mut t: Vec<Trade> = (0..90)
819 .map(|i| trade(1_000 + i * 977, 100.0, 0.001, TradeSide::Buy))
820 .collect();
821 t.extend(
822 (0..10).map(|i| trade(500_000 + i * 3_331, 100.0, 50.0 + i as f64, TradeSide::Sell)),
823 );
824 let p = detect(Ok(&t), None);
825 assert_eq!(p.bot_like_pct, Some(90.0));
826 let v = p.bot_like_volume_pct.unwrap();
827 assert!(v < 0.05, "{v}");
828 let crate::market::types::HealthCheck::Warn(m) = p.check() else {
829 panic!()
830 };
831 assert!(
832 m.contains("90% of sampled trades (0.0% of their volume) look bot-made"),
833 "{m}"
834 );
835 }
836
837 #[test]
838 fn test_equal_levels_at_even_steps_are_a_ladder() {
839 let asks: Vec<_> = (0..12)
840 .map(|i| level(1.0001 + i as f64 * 0.0001, 5_000.0))
841 .collect();
842 let bids: Vec<_> = (0..12)
844 .map(|i| level(0.9999 - i as f64 * 0.0001, 100.0 + (i * 53 % 211) as f64))
845 .collect();
846 let found = ladders(&bids, &asks);
847 assert_eq!(found.len(), 1);
848 let Finding::Ladder {
849 side,
850 levels,
851 shape,
852 size,
853 step_pct,
854 ..
855 } = &found[0]
856 else {
857 panic!()
858 };
859 assert_eq!(
860 (*side, *levels, *shape, *size),
861 (BookSide::Ask, 12, LadderShape::Even, 5_000.0)
862 );
863 assert!((step_pct - 0.01).abs() < 0.001, "{step_pct}");
864 let short_run: Vec<_> = (0..7)
866 .map(|i| level(1.0 + i as f64 * 0.0001, 5_000.0))
867 .collect();
868 assert!(ladders(&[], &short_run).is_empty());
869 }
870
871 #[test]
872 fn test_findings_warn_with_their_evidence_and_the_bot_like_share() {
873 let mut t = organic(100);
874 for tr in t.iter_mut().step_by(2) {
875 tr.quantity = 1000.0;
876 }
877 let p = detect(Ok(&t), None);
878 let crate::market::types::HealthCheck::Warn(m) = p.check() else {
879 panic!("{:?}", p.check())
880 };
881 assert!(
882 m.starts_with(
883 "Bot patterns (likely market-making bots): 50% of 100 trades are exactly 1000"
884 ),
885 "{m}"
886 );
887 assert!(m.contains("50% of sampled trades ("), "{m}");
888 assert!(m.contains("% of their volume) look bot-made"), "{m}");
889 assert_eq!(p.bot_like_pct, Some(50.0));
890 }
891
892 #[test]
893 fn test_no_tape_or_a_short_tape_says_why_and_still_reads_the_book() {
894 let p = detect(Err("the venue gives no trade feed"), None);
895 assert_eq!(p.trades_sampled, 0);
896 let crate::market::types::HealthCheck::NotApplicable(m) = p.check() else {
897 panic!()
898 };
899 assert!(m.contains("the venue gives no trade feed"), "{m}");
900 let asks: Vec<_> = (0..10)
902 .map(|i| level(1.0 + i as f64 * 0.001, 42.0))
903 .collect();
904 let p = detect(Err("x"), Some((&[], &asks)));
905 assert!(matches!(
906 p.check(),
907 crate::market::types::HealthCheck::Warn(_)
908 ));
909 let t = organic(MIN_TRADES - 1);
911 let p = detect(Ok(&t), None);
912 assert!(p.tape_note.unwrap().contains("only 29 recent trades"));
913 assert!(p.bot_like_pct.is_none());
914 }
915
916 #[test]
917 fn test_each_tape_signal_alone_refuses_a_short_tape() {
918 let t: Vec<Trade> = (0..MIN_TRADES as u64 - 1)
922 .map(|i| {
923 let side = if i % 2 == 0 {
924 TradeSide::Buy
925 } else {
926 TradeSide::Sell
927 };
928 trade(i * 2_000, 1.0, 10.0, side)
929 })
930 .collect();
931 assert!(repeated_size(&t).is_none());
932 assert!(metronome(&t).is_none());
933 assert!(ping_pong(&t).is_none());
934 }
935
936 #[test]
937 fn test_a_bid_ladder_is_described_as_a_bid_ladder() {
938 let bids: Vec<_> = (0..9)
939 .map(|i| level(0.999 - i as f64 * 0.001, 250.0))
940 .collect();
941 let found = ladders(&bids, &[]);
942 assert_eq!(found.len(), 1);
943 let d = found[0].describe();
944 assert!(d.starts_with("bid ladder of 9 levels × 250 at "), "{d}");
945 }
946
947 #[test]
948 fn test_a_tape_with_no_volume_gives_the_trade_share_only() {
949 let t: Vec<Trade> = (0..40)
952 .map(|i| trade(1_000 + i * 977, 0.0, 3.0, TradeSide::Buy))
953 .collect();
954 let p = detect(Ok(&t), None);
955 assert_eq!(p.bot_like_pct, Some(100.0));
956 assert_eq!(p.bot_like_volume_pct, None);
957 let crate::market::types::HealthCheck::Warn(m) = p.check() else {
958 panic!("{:?}", p.check())
959 };
960 assert!(m.ends_with("; 100% of sampled trades look bot-made"), "{m}");
961 }
962
963 fn weex_book() -> (Vec<OrderBookLevel>, Vec<OrderBookLevel>) {
966 let bids = [
967 (0.9999, 410.046),
968 (0.9998, 540.108),
969 (0.9997, 450.135),
970 (0.9996, 370.148),
971 (0.9995, 370.185),
972 (0.9994, 360.2161),
973 (0.9993, 300.2101),
974 (0.9992, 220.1761),
975 (0.9991, 160.1441),
976 (0.9990, 120.1201),
977 (0.0022, 3636.3636),
978 ];
979 let asks = [
980 (1.0001, 427.318),
981 (1.0002, 1349.73),
982 (1.0003, 1209.6371),
983 (1.0004, 1029.5881),
984 (1.0005, 959.5202),
985 (1.0006, 919.4483),
986 (1.0007, 799.4403),
987 (1.0008, 619.5043),
988 (1.0009, 399.6403),
989 (1.0010, 299.7002),
990 (1.0500, 4.8046),
991 ];
992 let side = |l: &[(f64, f64)]| l.iter().map(|&(p, q)| level(p, q)).collect();
993 (side(&bids), side(&asks))
994 }
995
996 fn weex_tape(cycles: u64) -> Vec<Trade> {
1000 let mut t = Vec::new();
1001 for c in 0..cycles {
1002 let start = 1_000_000 + c * 1_760_000;
1003 let burst = if c % 2 == 0 { 498.11 } else { 68.01 };
1004 let script = [
1005 (0.9999, 500.0, TradeSide::Sell),
1006 (0.9999, 60.05 + (c % 3) as f64 * 10.0, TradeSide::Sell),
1007 (1.0001, burst, TradeSide::Buy),
1008 (1.0001, burst, TradeSide::Buy),
1009 (1.0001, burst, TradeSide::Buy),
1010 (1.0001, burst, TradeSide::Buy),
1011 (1.0001, 300.0 + c as f64 * 7.3, TradeSide::Buy),
1012 (1.0001, 60.0 + c as f64 * 1.9, TradeSide::Buy),
1013 (1.0001, 12.0 + c as f64 * 0.37, TradeSide::Buy),
1014 ];
1015 for (k, (price, qty, side)) in script.into_iter().enumerate() {
1016 let jitter = (c * 7 + k as u64 * 13) % 40;
1018 t.push(trade(start + k as u64 * 45_000 + jitter, price, qty, side));
1019 }
1020 }
1021 t
1022 }
1023
1024 #[test]
1025 fn test_the_weex_usdn_bot_is_caught_on_the_tape_and_the_book() {
1026 let tape = weex_tape(40);
1027 let (bids, asks) = weex_book();
1028 let p = detect(Ok(&tape), Some((&bids, &asks)));
1029 let kinds: Vec<&str> = p
1030 .findings
1031 .iter()
1032 .map(|f| match f {
1033 Finding::RepeatedSize { .. } => "repeated",
1034 Finding::FewSizes { .. } => "few",
1035 Finding::Metronome { .. } => "metronome",
1036 Finding::Cadence { .. } => "cadence",
1037 Finding::PingPong { .. } => "ping-pong",
1038 Finding::Ladder { .. } => "ladder",
1039 })
1040 .collect();
1041 assert_eq!(
1042 kinds,
1043 ["few", "cadence", "ladder", "ladder"],
1044 "{:?}",
1045 p.findings
1046 );
1047 let crate::market::types::HealthCheck::Warn(m) = p.check() else {
1048 panic!("{:?}", p.check())
1049 };
1050 assert!(
1051 m.contains("trades are one of 3 sizes: 498.11, 68.01, 500"),
1052 "{m}"
1053 );
1054 assert!(m.contains("gaps between trades are 45.0s ±2%"), "{m}");
1055 assert!(
1056 m.contains("bid ladder of 9 levels falling 540.108 → 120.1201 at 0.01% steps"),
1057 "{m}"
1058 );
1059 assert!(
1060 m.contains("ask ladder of 9 levels falling 1350 → 299.7002 at 0.01% steps"),
1061 "{m}"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_a_few_sizes_in_half_the_tape_is_a_signal_one_rare_size_is_not() {
1067 let mut t = organic(100);
1068 for (i, tr) in t.iter_mut().enumerate() {
1069 match i % 10 {
1070 0 | 1 => tr.quantity = 498.11,
1071 2 | 3 => tr.quantity = 68.01,
1072 4 | 5 => tr.quantity = 500.0,
1073 _ => {}
1074 }
1075 }
1076 assert!(repeated_size(&t).is_none());
1078 let (f, idx) = few_sizes(&t).unwrap();
1079 assert_eq!(idx.len(), 60);
1080 assert!(
1081 matches!(&f, Finding::FewSizes { sizes, count: 60, of: 100 } if sizes == &[498.11, 68.01, 500.0]),
1082 "{f:?}"
1083 );
1084 let mut t = organic(100);
1086 for (i, tr) in t.iter_mut().enumerate() {
1087 if i % 20 < 9 {
1088 tr.quantity = if i % 2 == 0 { 498.11 } else { 68.01 };
1089 }
1090 }
1091 assert!(few_sizes(&t).is_none(), "{:?}", few_sizes(&t));
1092 assert!(few_sizes(&organic(200)).is_none());
1093 }
1094
1095 #[test]
1096 fn test_short_tapes_and_few_gaps_are_not_judged() {
1097 let short_tape = weex_tape(3);
1098 assert!(short_tape.len() < MIN_TRADES);
1099 assert!(few_sizes(&short_tape).is_none());
1100 assert!(cadence(&short_tape).is_none());
1101 let bunched: Vec<Trade> = (0..60u64)
1103 .map(|i| trade((i % 10) * 45_000, 1.0, 5.0 + i as f64, TradeSide::Buy))
1104 .collect();
1105 assert!(cadence(&bunched).is_none());
1106 let same: Vec<Trade> = (0..40)
1108 .map(|i| trade(7, 1.0, 5.0 + i as f64, TradeSide::Buy))
1109 .collect();
1110 assert!(metronome(&same).is_none());
1111 }
1112
1113 #[test]
1114 fn test_steps_read_to_two_significant_digits() {
1115 assert_eq!(step(0.010002), "0.01");
1116 assert_eq!(step(0.123), "0.12");
1117 assert_eq!(step(0.5), "0.5");
1118 assert_eq!(step(12.3), "12");
1119 assert_eq!(step(0.0), "0");
1120 }
1121
1122 #[test]
1123 fn test_bursts_at_one_interval_are_a_cadence_but_not_a_metronome() {
1124 let t = weex_tape(30);
1125 assert!(metronome(&t).is_none());
1126 let Some(Finding::Cadence { gap_ms, share, .. }) = cadence(&t) else {
1127 panic!("{:?}", cadence(&t))
1128 };
1129 assert!((gap_ms - 45_000.0).abs() < 50.0, "{gap_ms}");
1130 assert!(share > 0.85, "{share}");
1131 assert!(cadence(&organic(300)).is_none());
1132 }
1133
1134 #[test]
1135 fn test_random_gaps_in_whole_seconds_are_not_a_cadence() {
1136 let mut ms = 0;
1138 let t: Vec<Trade> = (0..400u64)
1139 .map(|i| {
1140 ms += 1_000 * (1 + (i * 7919 + i * i * 31) % 40);
1141 trade(ms, 1.0, 5.0 + i as f64, TradeSide::Buy)
1142 })
1143 .collect();
1144 assert!(cadence(&t).is_none(), "{:?}", cadence(&t));
1145 let fast: Vec<Trade> = (0..100u64)
1147 .map(|i| {
1148 trade(
1149 i * 1_000 + (i % 2) * 2_000,
1150 1.0,
1151 5.0 + i as f64,
1152 TradeSide::Buy,
1153 )
1154 })
1155 .collect();
1156 assert!(cadence(&fast).is_none(), "{:?}", cadence(&fast));
1157 }
1158
1159 #[test]
1160 fn test_sizes_that_fall_or_rise_level_by_level_are_a_shaped_ladder() {
1161 let (bids, asks) = weex_book();
1162 let found = ladders(&bids, &asks);
1163 assert_eq!(found.len(), 2, "{found:?}");
1164 for f in &found {
1165 assert!(
1166 matches!(
1167 f,
1168 Finding::Ladder {
1169 levels: 9,
1170 shape: LadderShape::Falling,
1171 ..
1172 }
1173 ),
1174 "{f:?}"
1175 );
1176 }
1177 let rising: Vec<_> = (0..10)
1178 .map(|i| level(0.9999 - i as f64 * 0.0001, 100.0 + i as f64 * 25.0))
1179 .collect();
1180 let found = ladders(&rising, &[]);
1181 assert!(
1182 matches!(
1183 found[..],
1184 [Finding::Ladder {
1185 levels: 10,
1186 shape: LadderShape::Rising,
1187 ..
1188 }]
1189 ),
1190 "{found:?}"
1191 );
1192 assert!(
1193 found[0].describe().contains("rising 100 → 325"),
1194 "{}",
1195 found[0].describe()
1196 );
1197 let zigzag: Vec<_> = [
1199 100.0, 200.0, 150.0, 300.0, 250.0, 400.0, 350.0, 500.0, 450.0,
1200 ]
1201 .iter()
1202 .enumerate()
1203 .map(|(i, &q)| level(1.0001 + i as f64 * 0.0001, q))
1204 .collect();
1205 assert!(ladders(&[], &zigzag).is_empty());
1206 }
1207}