1use super::types::{BookSide, OrderBookLevel, Trade, TradeSide};
32use serde::Serialize;
33
34pub const TRADE_SAMPLE: u32 = 500;
36
37pub const MIN_TRADES: usize = 30;
39
40pub const REPEATED_SIZE_SHARE: f64 = 0.30;
42
43pub const FEW_SIZES: usize = 3;
45
46pub const FEW_SIZES_SHARE: f64 = 0.50;
49
50pub const FEW_SIZES_MIN_SHARE: f64 = 0.05;
53
54pub const MIN_GAPS: usize = 20;
56
57pub const METRONOME_MAX_CV: f64 = 0.10;
60
61pub const TIMER_MAX_CV: f64 = 0.35;
64
65pub const TIMER_MIN_FLOOR: f64 = 0.40;
68
69pub const CADENCE_TOLERANCE: f64 = 0.02;
71
72pub const CADENCE_SHARE: f64 = 0.30;
75
76pub const CADENCE_MIN_GAP_MS: f64 = 5_000.0;
79
80pub const PING_PONG_WINDOW_MS: u64 = 5_000;
82
83pub const PING_PONG_PRICE_TOLERANCE_PCT: f64 = 0.05;
85
86pub const PING_PONG_SHARE: f64 = 0.20;
89
90pub const MIN_PING_PONG_PAIRS: usize = 5;
92
93pub const LADDER_MIN_LEVELS: usize = 8;
95
96pub const LADDER_SIZE_TOLERANCE: f64 = 0.01;
98
99pub const LADDER_SHAPE_SLACK: f64 = 0.05;
102
103pub const ROUND_LEVELS: usize = 10;
105
106pub const ROUND_MIN_LEVELS: usize = 8;
109
110const ROUND_TOLERANCE: f64 = 0.005;
112
113pub const LADDER_STEP_TOLERANCE: f64 = 0.05;
115
116pub const CYCLE_JUMP: f64 = 0.25;
118
119pub const CYCLE_MIN: usize = 3;
121
122pub const CYCLE_MIN_RUNS: usize = 3;
125
126pub const CYCLE_MAX_CV: f64 = 0.20;
129
130pub const CYCLE_FALLING_SHARE: f64 = 0.60;
133
134pub const LADDER_DEPTH: usize = 25;
136
137const SIZE_DECIMALS: f64 = 1e8;
140
141#[derive(Debug, Clone, PartialEq, Serialize)]
143#[serde(tag = "kind", rename_all = "snake_case")]
144pub enum Finding {
145 RepeatedSize { size: f64, count: usize, of: usize },
147 FewSizes {
150 sizes: Vec<f64>,
151 count: usize,
152 of: usize,
153 },
154 Metronome {
158 mean_gap_ms: f64,
159 cv: f64,
160 gaps: usize,
161 dominant_size: Option<f64>,
162 },
163 Cadence {
166 gap_ms: f64,
167 share: f64,
168 gaps: usize,
169 },
170 Timer {
174 low_ms: f64,
175 median_ms: f64,
176 high_ms: f64,
177 cv: f64,
178 gaps: usize,
179 },
180 PingPong { pairs: usize, share: f64 },
183 Ladder {
187 side: BookSide,
188 levels: usize,
189 shape: LadderShape,
190 size: f64,
191 last_size: f64,
192 step_pct: f64,
193 },
194 RoundNotional {
197 side: BookSide,
198 levels: usize,
199 of: usize,
200 examples: Vec<f64>,
201 },
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
206#[serde(rename_all = "snake_case")]
207pub enum LadderShape {
208 Even,
210 Falling,
213 Rising,
216}
217
218#[derive(Debug, Clone, PartialEq, Serialize)]
221pub struct DepthCycle {
222 pub period_ms: f64,
224 pub cycles: usize,
226 pub cv: f64,
228}
229
230#[derive(Debug, Clone, PartialEq, Serialize)]
232pub struct BotPatterns {
233 pub trades_sampled: usize,
235 pub tape_note: Option<String>,
237 pub findings: Vec<Finding>,
239 pub bot_like_pct: Option<f64>,
242 pub bot_like_volume_pct: Option<f64>,
245 pub book_checked: bool,
247}
248
249fn size_key(quantity: f64) -> i64 {
251 (quantity * SIZE_DECIMALS).round() as i64
252}
253
254fn sizes_by_count(trades: &[Trade]) -> Vec<(f64, Vec<usize>)> {
256 let mut by_size: std::collections::HashMap<i64, Vec<usize>> = Default::default();
257 for (i, t) in trades.iter().enumerate() {
258 if t.quantity > 0.0 {
259 by_size.entry(size_key(t.quantity)).or_default().push(i);
260 }
261 }
262 let mut sizes: Vec<Vec<usize>> = by_size.into_values().collect();
263 sizes.sort_by(|a, b| b.len().cmp(&a.len()).then(a[0].cmp(&b[0])));
266 sizes
267 .into_iter()
268 .map(|idx| (trades[idx[0]].quantity, idx))
269 .collect()
270}
271
272fn dominant_size(trades: &[Trade]) -> Option<(f64, Vec<usize>)> {
274 sizes_by_count(trades).into_iter().next()
275}
276
277pub fn repeated_size(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
280 if trades.len() < MIN_TRADES {
281 return None;
282 }
283 let (size, idx) = dominant_size(trades)?;
284 (idx.len() as f64 / trades.len() as f64 >= REPEATED_SIZE_SHARE).then_some((
285 Finding::RepeatedSize {
286 size,
287 count: idx.len(),
288 of: trades.len(),
289 },
290 idx,
291 ))
292}
293
294pub fn few_sizes(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
298 if trades.len() < MIN_TRADES {
299 return None;
300 }
301 let n = trades.len() as f64;
302 let top: Vec<(f64, Vec<usize>)> = sizes_by_count(trades)
303 .into_iter()
304 .take(FEW_SIZES)
305 .filter(|(_, idx)| idx.len() as f64 / n >= FEW_SIZES_MIN_SHARE)
306 .collect();
307 let idx: Vec<usize> = top
308 .iter()
309 .flat_map(|(_, idx)| idx.iter().copied())
310 .collect();
311 (top.len() > 1 && idx.len() as f64 / n >= FEW_SIZES_SHARE).then(|| {
312 (
313 Finding::FewSizes {
314 sizes: top.iter().map(|(s, _)| *s).collect(),
315 count: idx.len(),
316 of: trades.len(),
317 },
318 idx,
319 )
320 })
321}
322
323fn event_gaps(mut times: Vec<u64>) -> Vec<f64> {
326 times.sort_unstable();
327 times.dedup();
328 times.windows(2).map(|w| (w[1] - w[0]) as f64).collect()
329}
330
331fn mean_cv(gaps: &[f64]) -> Option<(f64, f64)> {
333 let n = gaps.len() as f64;
334 let mean = gaps.iter().sum::<f64>() / n;
335 if mean <= 0.0 {
336 return None;
337 }
338 let var = gaps.iter().map(|g| (g - mean).powi(2)).sum::<f64>() / n;
339 Some((mean, var.sqrt() / mean))
340}
341
342pub fn metronome(trades: &[Trade]) -> Option<Finding> {
345 if trades.len() < MIN_TRADES {
346 return None;
347 }
348 let judge = |times: Vec<u64>, dominant: Option<f64>| {
349 let gaps = event_gaps(times);
350 if gaps.len() < MIN_GAPS {
351 return None;
352 }
353 let (mean, cv) = mean_cv(&gaps)?;
354 (cv < METRONOME_MAX_CV).then_some(Finding::Metronome {
355 mean_gap_ms: mean,
356 cv,
357 gaps: gaps.len(),
358 dominant_size: dominant,
359 })
360 };
361 judge(trades.iter().map(|t| t.timestamp_ms).collect(), None).or_else(|| {
362 let (size, idx) = dominant_size(trades)?;
363 judge(
364 idx.iter().map(|&i| trades[i].timestamp_ms).collect(),
365 Some(size),
366 )
367 })
368}
369
370pub fn cadence(trades: &[Trade]) -> Option<Finding> {
374 if trades.len() < MIN_TRADES {
375 return None;
376 }
377 let mut gaps = event_gaps(trades.iter().map(|t| t.timestamp_ms).collect());
378 if gaps.len() < MIN_GAPS {
379 return None;
380 }
381 gaps.sort_by(f64::total_cmp);
382 let (mut lo, mut hi) = (0, 0);
385 let mut best: Option<(usize, usize)> = None; for (i, &g) in gaps.iter().enumerate() {
387 if g < CADENCE_MIN_GAP_MS {
388 continue;
389 }
390 while gaps[lo] < g * (1.0 - CADENCE_TOLERANCE) {
391 lo += 1;
392 }
393 hi = hi.max(i);
394 while hi + 1 < gaps.len() && gaps[hi + 1] <= g * (1.0 + CADENCE_TOLERANCE) {
395 hi += 1;
396 }
397 let count = hi + 1 - lo;
398 if best.is_none_or(|(c, _)| count > c) {
399 best = Some((count, i));
400 }
401 }
402 let (count, i) = best?;
403 let share = count as f64 / gaps.len() as f64;
404 (share >= CADENCE_SHARE).then_some(Finding::Cadence {
405 gap_ms: gaps[i],
406 share,
407 gaps: gaps.len(),
408 })
409}
410
411pub fn depth_cycle(points: &[(i64, f64)]) -> Option<DepthCycle> {
417 let refills: Vec<usize> = (1..points.len())
418 .filter(|&i| {
419 let (a, b) = (points[i - 1].1, points[i].1);
420 a > 0.0 && b >= a * (1.0 + CYCLE_JUMP)
421 })
422 .collect();
423 if refills.len() < CYCLE_MIN + 1 || refills.windows(2).any(|w| w[1] - w[0] < CYCLE_MIN_RUNS) {
424 return None;
425 }
426 let periods: Vec<f64> = refills
427 .windows(2)
428 .map(|w| (points[w[1]].0 - points[w[0]].0) as f64)
429 .collect();
430 let (mean, cv) = mean_cv(&periods)?;
431 if cv >= CYCLE_MAX_CV {
432 return None;
433 }
434 let steps: Vec<bool> = refills
435 .windows(2)
436 .flat_map(|w| w[0] + 1..w[1])
437 .map(|i| points[i].1 <= points[i - 1].1 * (1.0 + LADDER_SIZE_TOLERANCE))
438 .collect();
439 let falling = steps.iter().filter(|f| **f).count() as f64 / steps.len() as f64;
440 (falling >= CYCLE_FALLING_SHARE).then_some(DepthCycle {
441 period_ms: mean,
442 cycles: refills.len() - 1,
443 cv,
444 })
445}
446
447pub fn timer(trades: &[Trade]) -> Option<Finding> {
452 if trades.len() < MIN_TRADES {
453 return None;
454 }
455 let mut gaps = event_gaps(trades.iter().map(|t| t.timestamp_ms).collect());
456 if gaps.len() < MIN_GAPS {
457 return None;
458 }
459 let (_, cv) = mean_cv(&gaps)?;
460 gaps.sort_by(f64::total_cmp);
461 let at = |q: f64| gaps[((gaps.len() - 1) as f64 * q).round() as usize];
462 let (low, median, high) = (at(0.05), at(0.5), at(0.95));
463 (median >= CADENCE_MIN_GAP_MS && cv < TIMER_MAX_CV && low >= median * TIMER_MIN_FLOOR)
464 .then_some(Finding::Timer {
465 low_ms: low,
466 median_ms: median,
467 high_ms: high,
468 cv,
469 gaps: gaps.len(),
470 })
471}
472
473pub fn ping_pong(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
477 if trades.len() < MIN_TRADES {
478 return None;
479 }
480 let mut order: Vec<usize> = (0..trades.len()).collect();
481 order.sort_by_key(|&i| trades[i].timestamp_ms);
482 let mut paired = vec![false; trades.len()];
483 let mut members = Vec::new();
484 for (pos, &i) in order.iter().enumerate() {
485 if paired[i] {
486 continue;
487 }
488 let a = &trades[i];
489 for &j in &order[pos + 1..] {
490 let b = &trades[j];
491 if b.timestamp_ms - a.timestamp_ms > PING_PONG_WINDOW_MS {
492 break;
493 }
494 let opposite = matches!(
495 (&a.side, &b.side),
496 (TradeSide::Buy, TradeSide::Sell) | (TradeSide::Sell, TradeSide::Buy)
497 );
498 let near = a.price > 0.0
499 && ((b.price - a.price).abs() / a.price * 100.0) <= PING_PONG_PRICE_TOLERANCE_PCT;
500 if !paired[j] && opposite && near && size_key(a.quantity) == size_key(b.quantity) {
501 paired[i] = true;
502 paired[j] = true;
503 members.extend([i, j]);
504 break;
505 }
506 }
507 }
508 let pairs = members.len() / 2;
509 let share = members.len() as f64 / trades.len() as f64;
510 (pairs >= MIN_PING_PONG_PAIRS && share >= PING_PONG_SHARE)
511 .then_some((Finding::PingPong { pairs, share }, members))
512}
513
514fn size_direction(a: f64, b: f64) -> i8 {
517 if (b - a).abs() <= a * LADDER_SIZE_TOLERANCE {
518 0
519 } else if b > a {
520 1
521 } else {
522 -1
523 }
524}
525
526fn ladder_side(levels: &[OrderBookLevel], side: BookSide) -> Option<Finding> {
529 let levels: Vec<&OrderBookLevel> = levels
530 .iter()
531 .filter(|l| l.price > 0.0 && l.quantity > 0.0)
532 .take(LADDER_DEPTH)
533 .collect();
534 let mut best: Option<(usize, usize, i8)> = None; let mut start = 0;
536 while start + 1 < levels.len() {
537 let step = (levels[start + 1].price - levels[start].price).abs();
538 let mut dir = 0;
539 let mut end = start + 1;
540 while end < levels.len() {
541 let this_step = (levels[end].price - levels[end - 1].price).abs();
542 let same_step = step > 0.0 && (this_step - step).abs() <= step * LADDER_STEP_TOLERANCE;
543 let (a, b) = (levels[end - 1].quantity, levels[end].quantity);
544 let mut d = size_direction(a, b);
545 if d != 0 && dir != 0 && d != dir && (b - a).abs() <= a * LADDER_SHAPE_SLACK {
547 d = 0;
548 }
549 if !same_step || (d != 0 && dir != 0 && d != dir) {
550 break;
551 }
552 if d != 0 {
553 dir = d;
554 }
555 end += 1;
556 }
557 let len = end - start;
558 if best.is_none_or(|(_, l, _)| len > l) {
559 best = Some((start, len, dir));
560 }
561 start = (end - 1).max(start + 1);
564 }
565 let (s, len, dir) = best?;
566 if len < LADDER_MIN_LEVELS {
567 return None;
568 }
569 let (first, last) = (levels[s].quantity, levels[s + len - 1].quantity);
570 let even = levels[s..s + len]
571 .iter()
572 .all(|l| size_direction(first, l.quantity) == 0);
573 let shape = match dir {
574 _ if even => LadderShape::Even,
575 1 => LadderShape::Rising,
576 _ => LadderShape::Falling,
577 };
578 let step = (levels[s + 1].price - levels[s].price).abs();
579 Some(Finding::Ladder {
580 side,
581 levels: len,
582 shape,
583 size: first,
584 last_size: last,
585 step_pct: step / levels[s].price * 100.0,
586 })
587}
588
589fn round_notional_side(levels: &[OrderBookLevel], side: BookSide) -> Option<Finding> {
593 let top: Vec<&OrderBookLevel> = levels
594 .iter()
595 .filter(|l| l.price > 0.0 && l.quantity > 0.0)
596 .take(ROUND_LEVELS)
597 .collect();
598 let round: Vec<f64> = top
599 .iter()
600 .filter_map(|l| {
601 let notional = l.price * l.quantity;
602 let whole = (notional - notional.round()).abs() < ROUND_TOLERANCE;
603 let whole_size = (l.quantity - l.quantity.round()).abs() < 1e-9;
604 (whole && !whole_size && notional.round() > 0.0).then_some(notional.round())
605 })
606 .collect();
607 (round.len() >= ROUND_MIN_LEVELS).then(|| Finding::RoundNotional {
608 side,
609 levels: round.len(),
610 of: top.len(),
611 examples: round.into_iter().take(3).collect(),
612 })
613}
614
615pub fn round_notionals(bids: &[OrderBookLevel], asks: &[OrderBookLevel]) -> Vec<Finding> {
617 [
618 round_notional_side(bids, BookSide::Bid),
619 round_notional_side(asks, BookSide::Ask),
620 ]
621 .into_iter()
622 .flatten()
623 .collect()
624}
625
626pub fn ladders(bids: &[OrderBookLevel], asks: &[OrderBookLevel]) -> Vec<Finding> {
628 [
629 ladder_side(bids, BookSide::Bid),
630 ladder_side(asks, BookSide::Ask),
631 ]
632 .into_iter()
633 .flatten()
634 .collect()
635}
636
637pub fn detect(
641 trades: Result<&[Trade], &str>,
642 book: Option<(&[OrderBookLevel], &[OrderBookLevel])>,
643) -> BotPatterns {
644 let mut findings = Vec::new();
645 let mut tape_note = None;
646 let mut bot_like_pct = None;
647 let mut bot_like_volume_pct = None;
648 let mut sampled = 0;
649 match trades {
650 Err(why) => tape_note = Some(why.to_string()),
651 Ok(t) if t.len() < MIN_TRADES => {
652 sampled = t.len();
653 tape_note = Some(format!(
654 "only {} recent trades; the tape signals need {MIN_TRADES}",
655 t.len()
656 ));
657 }
658 Ok(t) => {
659 sampled = t.len();
660 let mut covered = vec![false; t.len()];
661 if let Some((f, idx)) = repeated_size(t).or_else(|| few_sizes(t)) {
663 idx.iter().for_each(|&i| covered[i] = true);
664 findings.push(f);
665 }
666 if let Some(f) = metronome(t).or_else(|| cadence(t)).or_else(|| timer(t)) {
669 findings.push(f);
670 }
671 if let Some((f, idx)) = ping_pong(t) {
672 idx.iter().for_each(|&i| covered[i] = true);
673 findings.push(f);
674 }
675 let n = covered.iter().filter(|c| **c).count();
676 bot_like_pct = Some(n as f64 / t.len() as f64 * 100.0);
677 let value = |tr: &Trade| tr.quote_quantity.unwrap_or(tr.price * tr.quantity);
678 let total: f64 = t.iter().map(value).sum();
679 let bot: f64 = t
680 .iter()
681 .zip(&covered)
682 .filter(|(_, c)| **c)
683 .map(|(tr, _)| value(tr))
684 .sum();
685 bot_like_volume_pct = (total > 0.0).then(|| bot / total * 100.0 + 0.0);
687 }
688 }
689 if let Some((bids, asks)) = book {
690 findings.extend(ladders(bids, asks));
691 findings.extend(round_notionals(bids, asks));
692 }
693 BotPatterns {
694 trades_sampled: sampled,
695 tape_note,
696 findings,
697 bot_like_pct,
698 bot_like_volume_pct,
699 book_checked: book.is_some(),
700 }
701}
702
703fn short(v: f64) -> String {
705 let s = format!("{:.6}", v);
706 let s = s.trim_end_matches('0').trim_end_matches('.');
707 if v.abs() >= 1000.0 {
708 format!("{v:.0}")
709 } else {
710 s.to_string()
711 }
712}
713
714fn step(pct: f64) -> String {
717 if pct <= 0.0 {
718 return short(pct);
719 }
720 let decimals = (1 - pct.log10().floor() as i32).max(0) as usize;
721 let s = format!("{pct:.decimals$}");
722 if s.contains('.') {
723 s.trim_end_matches('0').trim_end_matches('.').to_string()
724 } else {
725 s
726 }
727}
728
729impl Finding {
730 pub fn label(&self) -> String {
732 let side = |side: &BookSide| match side {
733 BookSide::Bid => "Bid",
734 BookSide::Ask => "Ask",
735 };
736 match self {
737 Finding::RepeatedSize { .. } => "Repeated size".to_string(),
738 Finding::FewSizes { .. } => "Few sizes".to_string(),
739 Finding::Metronome { .. } => "Metronome".to_string(),
740 Finding::Cadence { .. } => "Cadence".to_string(),
741 Finding::Timer { .. } => "Jittered timer".to_string(),
742 Finding::PingPong { .. } => "Ping-pong".to_string(),
743 Finding::Ladder { side: s, .. } => format!("{} ladder", side(s)),
744 Finding::RoundNotional { side: s, .. } => format!("{} round notionals", side(s)),
745 }
746 }
747
748 pub fn in_book(&self) -> bool {
750 matches!(self, Finding::Ladder { .. } | Finding::RoundNotional { .. })
751 }
752
753 pub fn detail(&self) -> String {
755 match self {
756 Finding::RepeatedSize { size, count, of } => format!(
757 "{:.0}% of {of} trades are exactly {}",
758 *count as f64 / *of as f64 * 100.0,
759 short(*size)
760 ),
761 Finding::Metronome {
762 mean_gap_ms,
763 cv,
764 gaps,
765 dominant_size,
766 } => {
767 let whose = dominant_size
768 .map(|s| format!("size-{} trades", short(s)))
769 .unwrap_or_else(|| "trades".to_string());
770 format!(
771 "{whose} every {:.1}s ±{:.0}% over {gaps} gaps",
772 mean_gap_ms / 1000.0,
773 cv * 100.0
774 )
775 }
776 Finding::FewSizes { sizes, count, of } => format!(
777 "{:.0}% of {of} trades are one of {} sizes: {}",
778 *count as f64 / *of as f64 * 100.0,
779 sizes.len(),
780 sizes
781 .iter()
782 .map(|s| short(*s))
783 .collect::<Vec<_>>()
784 .join(", ")
785 ),
786 Finding::Cadence {
787 gap_ms,
788 share,
789 gaps,
790 } => format!(
791 "{:.0}% of {gaps} gaps between trades are {:.1}s ±{:.0}%",
792 share * 100.0,
793 gap_ms / 1000.0,
794 CADENCE_TOLERANCE * 100.0
795 ),
796 Finding::Timer {
797 low_ms,
798 median_ms,
799 high_ms,
800 gaps,
801 ..
802 } => format!(
803 "90% of {gaps} gaps between trades are {:.0}–{:.0}s (median {:.0}s)",
804 low_ms / 1000.0,
805 high_ms / 1000.0,
806 median_ms / 1000.0
807 ),
808 Finding::RoundNotional {
809 levels,
810 of,
811 examples,
812 ..
813 } => format!(
814 "{levels} of {of} levels are whole quote amounts ({}, …)",
815 examples
816 .iter()
817 .map(|v| short(*v))
818 .collect::<Vec<_>>()
819 .join(", ")
820 ),
821 Finding::PingPong { pairs, share } => format!(
822 "{pairs} buy/sell pairs of one size, seconds apart ({:.0}% of trades)",
823 share * 100.0
824 ),
825 Finding::Ladder {
826 levels,
827 shape,
828 size,
829 last_size,
830 step_pct,
831 ..
832 } => {
833 let sizes = match shape {
834 LadderShape::Even => format!("× {}", short(*size)),
835 LadderShape::Falling => {
836 format!("falling {} → {}", short(*size), short(*last_size))
837 }
838 LadderShape::Rising => {
839 format!("rising {} → {}", short(*size), short(*last_size))
840 }
841 };
842 format!("{levels} levels {sizes} at {}% steps", step(*step_pct))
843 }
844 }
845 }
846
847 pub fn describe(&self) -> String {
849 format!("{}: {}", self.label(), self.detail())
850 }
851}
852
853impl BotPatterns {
854 pub fn share(&self) -> Option<String> {
857 let p = self.bot_like_pct.filter(|p| *p > 0.0)?;
858 Some(match self.bot_like_volume_pct {
859 Some(v) => format!("{p:.0}% of sampled trades ({v:.1}% of their volume) look bot-made"),
860 None => format!("{p:.0}% of sampled trades look bot-made"),
861 })
862 }
863
864 pub fn format_text(&self) -> String {
875 use crate::display::terminal as t;
876 let width = self
877 .findings
878 .iter()
879 .map(|f| f.label().chars().count())
880 .max()
881 .unwrap_or(0)
882 + 2;
883 let mut out = t::subsection_header("Bot Patterns · likely market-making bots");
884 out.push('\n');
885 let groups = [
886 ("Trades", false, format!("{} sampled", self.trades_sampled)),
887 ("Book", true, "top levels of each side".to_string()),
888 ];
889 for (name, in_book, about) in groups {
890 let found: Vec<&Finding> = self
891 .findings
892 .iter()
893 .filter(|f| f.in_book() == in_book)
894 .collect();
895 if found.is_empty() {
896 continue;
897 }
898 out.push_str(&t::kv_row(name, &about));
899 out.push('\n');
900 for f in found {
901 out.push_str(&t::labelled_warn(&f.label(), width, &f.detail()));
902 out.push('\n');
903 }
904 }
905 if let Some(note) = self
906 .tape_note
907 .as_ref()
908 .filter(|_| self.findings.iter().all(Finding::in_book))
909 {
910 out.push_str(&t::kv_row("Trades", &format!("not checked ({note})")));
911 out.push('\n');
912 }
913 if let Some(share) = self.share() {
914 out.push_str(&t::kv_row("Bot-like share", &share));
915 out.push('\n');
916 }
917 out
918 }
919
920 pub fn evidence(&self) -> Vec<String> {
923 let mut lines: Vec<String> = self.findings.iter().map(Finding::describe).collect();
924 lines.extend(self.share());
925 lines
926 }
927
928 pub fn check(&self) -> super::types::HealthCheck {
933 use super::types::HealthCheck;
934 if !self.findings.is_empty() {
935 let n = self.findings.len();
936 let names: Vec<String> = self
937 .findings
938 .iter()
939 .map(|f| f.label().to_lowercase())
940 .collect();
941 return HealthCheck::Warn(format!(
942 "Bot patterns: {n} {} of market-making bots ({})",
943 if n == 1 { "sign" } else { "signs" },
944 names.join(", ")
945 ));
946 }
947 let book = if self.book_checked {
948 " or the book"
949 } else {
950 ""
951 };
952 match &self.tape_note {
953 None => HealthCheck::Pass(format!(
954 "No bot patterns in {} recent trades{book}",
955 self.trades_sampled
956 )),
957 Some(why) if self.book_checked => HealthCheck::NotApplicable(format!(
958 "Bot patterns: none in the book; trades not checked ({why})"
959 )),
960 Some(why) => HealthCheck::NotApplicable(format!("Bot patterns: not checked ({why})")),
961 }
962 }
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968
969 fn trade(ms: u64, price: f64, qty: f64, side: TradeSide) -> Trade {
970 Trade {
971 price,
972 quantity: qty,
973 quote_quantity: None,
974 timestamp_ms: ms,
975 side,
976 id: None,
977 }
978 }
979
980 fn organic(n: usize) -> Vec<Trade> {
982 let mut ms = 1_000_000;
983 (0..n)
984 .map(|i| {
985 ms += 300 + (i as u64 * 7919) % 4_000;
986 let qty = 10.0 + ((i * 37) % 97) as f64 * 1.37;
987 let side = if (i * 13) % 3 == 0 {
988 TradeSide::Buy
989 } else {
990 TradeSide::Sell
991 };
992 trade(ms, 1.0 + ((i * 11) % 7) as f64 * 0.0001, qty, side)
993 })
994 .collect()
995 }
996
997 fn plain(text: &str) -> String {
999 let mut out = String::new();
1000 let mut chars = text.chars();
1001 while let Some(c) = chars.next() {
1002 if c == '' {
1003 for c in chars.by_ref() {
1004 if c == 'm' {
1005 break;
1006 }
1007 }
1008 } else {
1009 out.push(c);
1010 }
1011 }
1012 out
1013 }
1014
1015 fn level(price: f64, quantity: f64) -> OrderBookLevel {
1016 OrderBookLevel { price, quantity }
1017 }
1018
1019 #[test]
1020 fn test_an_organic_tape_and_book_show_nothing() {
1021 let t = organic(200);
1022 let bids: Vec<_> = (0..20)
1023 .map(|i| {
1024 level(
1025 0.999 - i as f64 * 0.0001 * (1.0 + (i % 3) as f64),
1026 100.0 + (i * 53 % 211) as f64,
1027 )
1028 })
1029 .collect();
1030 let p = detect(Ok(&t), Some((&bids, &[])));
1031 assert!(p.findings.is_empty(), "{:?}", p.findings);
1032 assert_eq!(p.bot_like_pct, Some(0.0));
1033 assert!(matches!(
1034 p.check(),
1035 crate::market::types::HealthCheck::Pass(_)
1036 ));
1037 }
1038
1039 #[test]
1040 fn test_one_size_in_a_third_of_the_tape_is_repeated_size() {
1041 let mut t = organic(100);
1042 for tr in t.iter_mut().step_by(3) {
1043 tr.quantity = 1000.0;
1044 }
1045 let (f, idx) = repeated_size(&t).unwrap();
1046 assert_eq!(idx.len(), 34);
1047 assert!(matches!(f, Finding::RepeatedSize { size, count: 34, of: 100 } if size == 1000.0));
1048 assert!(
1049 f.describe().contains("34% of 100 trades are exactly 1000"),
1050 "{}",
1051 f.describe()
1052 );
1053 let mut t = organic(100);
1055 for tr in t.iter_mut().step_by(4) {
1056 tr.quantity = 1000.0;
1057 }
1058 assert!(repeated_size(&t).is_none());
1059 }
1060
1061 #[test]
1062 fn test_a_fixed_cadence_is_a_metronome_and_same_ms_fills_are_one_event() {
1063 let mut t = Vec::new();
1065 for i in 0..60u64 {
1066 t.push(trade(i * 2_000, 1.0, 5.0 + i as f64, TradeSide::Buy));
1067 if i % 5 == 0 {
1068 t.push(trade(i * 2_000, 1.0, 7.0, TradeSide::Buy));
1069 }
1070 }
1071 let Some(Finding::Metronome {
1072 mean_gap_ms,
1073 cv,
1074 dominant_size: None,
1075 ..
1076 }) = metronome(&t)
1077 else {
1078 panic!("no metronome")
1079 };
1080 assert_eq!(mean_gap_ms, 2_000.0);
1081 assert!(cv < 1e-9);
1082 assert!(metronome(&organic(200)).is_none());
1083 }
1084
1085 #[test]
1086 fn test_a_bot_cadence_hidden_in_organic_trades_is_found_by_its_size() {
1087 let mut t = organic(80);
1089 let start = t[0].timestamp_ms;
1090 for i in 0..45u64 {
1091 t.push(trade(start + 7 + i * 3_000, 1.0, 250.0, TradeSide::Sell));
1092 }
1093 let Some(Finding::Metronome {
1094 dominant_size: Some(s),
1095 mean_gap_ms,
1096 ..
1097 }) = metronome(&t)
1098 else {
1099 panic!("no metronome: {:?}", metronome(&t))
1100 };
1101 assert_eq!(s, 250.0);
1102 assert_eq!(mean_gap_ms, 3_000.0);
1103 }
1104
1105 #[test]
1106 fn test_buy_sell_pairs_of_one_size_are_ping_pong() {
1107 let mut t = organic(60);
1108 let start = t.last().unwrap().timestamp_ms + 10_000;
1109 for i in 0..15u64 {
1110 let ms = start + i * 20_000;
1111 let qty = 100.0 + i as f64; t.push(trade(ms, 1.0, qty, TradeSide::Buy));
1113 t.push(trade(ms + 800, 1.0002, qty, TradeSide::Sell));
1114 }
1115 let (f, idx) = ping_pong(&t).unwrap();
1116 assert_eq!(idx.len(), 30);
1117 assert!(matches!(f, Finding::PingPong { pairs: 15, .. }), "{f:?}");
1118 let mut slow = organic(60);
1120 for i in 0..15u64 {
1121 let ms = 9_000_000 + i * 20_000;
1122 slow.push(trade(ms, 1.0, 50.5, TradeSide::Buy));
1123 slow.push(trade(
1124 ms + PING_PONG_WINDOW_MS + 1,
1125 1.0,
1126 50.5,
1127 TradeSide::Sell,
1128 ));
1129 }
1130 assert!(ping_pong(&slow).is_none());
1131 let mut far = organic(60);
1132 for i in 0..15u64 {
1133 let ms = 9_000_000 + i * 20_000;
1134 far.push(trade(ms, 1.0, 50.5, TradeSide::Buy));
1135 far.push(trade(ms + 100, 1.01, 50.5, TradeSide::Sell));
1136 }
1137 assert!(ping_pong(&far).is_none());
1138 }
1139
1140 #[test]
1141 fn test_many_dust_bot_trades_are_little_of_the_volume() {
1142 let mut t: Vec<Trade> = (0..90)
1144 .map(|i| trade(1_000 + i * 977, 100.0, 0.001, TradeSide::Buy))
1145 .collect();
1146 t.extend(
1147 (0..10).map(|i| trade(500_000 + i * 3_331, 100.0, 50.0 + i as f64, TradeSide::Sell)),
1148 );
1149 let p = detect(Ok(&t), None);
1150 assert_eq!(p.bot_like_pct, Some(90.0));
1151 let v = p.bot_like_volume_pct.unwrap();
1152 assert!(v < 0.05, "{v}");
1153 let crate::market::types::HealthCheck::Warn(_) = p.check() else {
1154 panic!()
1155 };
1156 let m = p.evidence().join("\n");
1157 assert!(
1158 m.contains("90% of sampled trades (0.0% of their volume) look bot-made"),
1159 "{m}"
1160 );
1161 }
1162
1163 #[test]
1164 fn test_equal_levels_at_even_steps_are_a_ladder() {
1165 let asks: Vec<_> = (0..12)
1166 .map(|i| level(1.0001 + i as f64 * 0.0001, 5_000.0))
1167 .collect();
1168 let bids: Vec<_> = (0..12)
1170 .map(|i| level(0.9999 - i as f64 * 0.0001, 100.0 + (i * 53 % 211) as f64))
1171 .collect();
1172 let found = ladders(&bids, &asks);
1173 assert_eq!(found.len(), 1);
1174 let Finding::Ladder {
1175 side,
1176 levels,
1177 shape,
1178 size,
1179 step_pct,
1180 ..
1181 } = &found[0]
1182 else {
1183 panic!()
1184 };
1185 assert_eq!(
1186 (*side, *levels, *shape, *size),
1187 (BookSide::Ask, 12, LadderShape::Even, 5_000.0)
1188 );
1189 assert!((step_pct - 0.01).abs() < 0.001, "{step_pct}");
1190 let short_run: Vec<_> = (0..7)
1192 .map(|i| level(1.0 + i as f64 * 0.0001, 5_000.0))
1193 .collect();
1194 assert!(ladders(&[], &short_run).is_empty());
1195 }
1196
1197 #[test]
1198 fn test_findings_warn_with_their_evidence_and_the_bot_like_share() {
1199 let mut t = organic(100);
1200 for tr in t.iter_mut().step_by(2) {
1201 tr.quantity = 1000.0;
1202 }
1203 let p = detect(Ok(&t), None);
1204 let crate::market::types::HealthCheck::Warn(_) = p.check() else {
1205 panic!("{:?}", p.check())
1206 };
1207 let m = p.evidence().join("\n");
1209 assert!(
1210 m.starts_with("Repeated size: 50% of 100 trades are exactly 1000"),
1211 "{m}"
1212 );
1213 assert!(m.contains("50% of sampled trades ("), "{m}");
1214 assert!(m.contains("% of their volume) look bot-made"), "{m}");
1215 assert_eq!(p.bot_like_pct, Some(50.0));
1216 }
1217
1218 #[test]
1219 fn test_no_tape_or_a_short_tape_says_why_and_still_reads_the_book() {
1220 let p = detect(Err("the venue gives no trade feed"), None);
1221 assert_eq!(p.trades_sampled, 0);
1222 let crate::market::types::HealthCheck::NotApplicable(m) = p.check() else {
1223 panic!()
1224 };
1225 assert!(m.contains("the venue gives no trade feed"), "{m}");
1226 let asks: Vec<_> = (0..10)
1228 .map(|i| level(1.0 + i as f64 * 0.001, 42.0))
1229 .collect();
1230 let p = detect(Err("x"), Some((&[], &asks)));
1231 assert!(matches!(
1232 p.check(),
1233 crate::market::types::HealthCheck::Warn(_)
1234 ));
1235 let t = organic(MIN_TRADES - 1);
1237 let p = detect(Ok(&t), None);
1238 assert!(p.tape_note.unwrap().contains("only 29 recent trades"));
1239 assert!(p.bot_like_pct.is_none());
1240 }
1241
1242 #[test]
1243 fn test_each_tape_signal_alone_refuses_a_short_tape() {
1244 let t: Vec<Trade> = (0..MIN_TRADES as u64 - 1)
1248 .map(|i| {
1249 let side = if i % 2 == 0 {
1250 TradeSide::Buy
1251 } else {
1252 TradeSide::Sell
1253 };
1254 trade(i * 2_000, 1.0, 10.0, side)
1255 })
1256 .collect();
1257 assert!(repeated_size(&t).is_none());
1258 assert!(metronome(&t).is_none());
1259 assert!(ping_pong(&t).is_none());
1260 }
1261
1262 #[test]
1263 fn test_a_bid_ladder_is_described_as_a_bid_ladder() {
1264 let bids: Vec<_> = (0..9)
1265 .map(|i| level(0.999 - i as f64 * 0.001, 250.0))
1266 .collect();
1267 let found = ladders(&bids, &[]);
1268 assert_eq!(found.len(), 1);
1269 let d = found[0].describe();
1270 assert!(d.starts_with("Bid ladder: 9 levels × 250 at "), "{d}");
1271 }
1272
1273 #[test]
1274 fn test_a_tape_with_no_volume_gives_the_trade_share_only() {
1275 let t: Vec<Trade> = (0..40)
1278 .map(|i| trade(1_000 + i * 977, 0.0, 3.0, TradeSide::Buy))
1279 .collect();
1280 let p = detect(Ok(&t), None);
1281 assert_eq!(p.bot_like_pct, Some(100.0));
1282 assert_eq!(p.bot_like_volume_pct, None);
1283 let crate::market::types::HealthCheck::Warn(_) = p.check() else {
1284 panic!("{:?}", p.check())
1285 };
1286 let m = p.evidence().join("\n");
1288 assert!(m.ends_with("\n100% of sampled trades look bot-made"), "{m}");
1289 }
1290
1291 fn weex_book() -> (Vec<OrderBookLevel>, Vec<OrderBookLevel>) {
1294 let bids = [
1295 (0.9999, 410.046),
1296 (0.9998, 540.108),
1297 (0.9997, 450.135),
1298 (0.9996, 370.148),
1299 (0.9995, 370.185),
1300 (0.9994, 360.2161),
1301 (0.9993, 300.2101),
1302 (0.9992, 220.1761),
1303 (0.9991, 160.1441),
1304 (0.9990, 120.1201),
1305 (0.0022, 3636.3636),
1306 ];
1307 let asks = [
1308 (1.0001, 427.318),
1309 (1.0002, 1349.73),
1310 (1.0003, 1209.6371),
1311 (1.0004, 1029.5881),
1312 (1.0005, 959.5202),
1313 (1.0006, 919.4483),
1314 (1.0007, 799.4403),
1315 (1.0008, 619.5043),
1316 (1.0009, 399.6403),
1317 (1.0010, 299.7002),
1318 (1.0500, 4.8046),
1319 ];
1320 let side = |l: &[(f64, f64)]| l.iter().map(|&(p, q)| level(p, q)).collect();
1321 (side(&bids), side(&asks))
1322 }
1323
1324 fn weex_tape(cycles: u64) -> Vec<Trade> {
1328 let mut t = Vec::new();
1329 for c in 0..cycles {
1330 let start = 1_000_000 + c * 1_760_000;
1331 let burst = if c % 2 == 0 { 498.11 } else { 68.01 };
1332 let script = [
1333 (0.9999, 500.0, TradeSide::Sell),
1334 (0.9999, 60.05 + (c % 3) as f64 * 10.0, TradeSide::Sell),
1335 (1.0001, burst, TradeSide::Buy),
1336 (1.0001, burst, TradeSide::Buy),
1337 (1.0001, burst, TradeSide::Buy),
1338 (1.0001, burst, TradeSide::Buy),
1339 (1.0001, 300.0 + c as f64 * 7.3, TradeSide::Buy),
1340 (1.0001, 60.0 + c as f64 * 1.9, TradeSide::Buy),
1341 (1.0001, 12.0 + c as f64 * 0.37, TradeSide::Buy),
1342 ];
1343 for (k, (price, qty, side)) in script.into_iter().enumerate() {
1344 let jitter = (c * 7 + k as u64 * 13) % 40;
1346 t.push(trade(start + k as u64 * 45_000 + jitter, price, qty, side));
1347 }
1348 }
1349 t
1350 }
1351
1352 #[test]
1353 fn test_the_weex_usdn_bot_is_caught_on_the_tape_and_the_book() {
1354 let tape = weex_tape(40);
1355 let (bids, asks) = weex_book();
1356 let p = detect(Ok(&tape), Some((&bids, &asks)));
1357 let kinds: Vec<&str> = p
1358 .findings
1359 .iter()
1360 .map(|f| match f {
1361 Finding::RepeatedSize { .. } => "repeated",
1362 Finding::FewSizes { .. } => "few",
1363 Finding::Metronome { .. } => "metronome",
1364 Finding::Cadence { .. } => "cadence",
1365 Finding::Timer { .. } => "timer",
1366 Finding::RoundNotional { .. } => "round",
1367 Finding::PingPong { .. } => "ping-pong",
1368 Finding::Ladder { .. } => "ladder",
1369 })
1370 .collect();
1371 assert_eq!(
1372 kinds,
1373 ["few", "cadence", "ladder", "ladder", "round", "round"],
1374 "{:?}",
1375 p.findings
1376 );
1377 let crate::market::types::HealthCheck::Warn(_) = p.check() else {
1378 panic!("{:?}", p.check())
1379 };
1380 let m = p.evidence().join("\n");
1382 assert!(
1383 m.contains("trades are one of 3 sizes: 498.11, 68.01, 500"),
1384 "{m}"
1385 );
1386 assert!(m.contains("gaps between trades are 45.0s ±2%"), "{m}");
1387 assert!(
1388 m.contains("Bid ladder: 9 levels falling 540.108 → 120.1201 at 0.01% steps"),
1389 "{m}"
1390 );
1391 assert!(
1392 m.contains("Ask ladder: 9 levels falling 1350 → 299.7002 at 0.01% steps"),
1393 "{m}"
1394 );
1395 }
1396
1397 #[test]
1398 fn test_a_few_sizes_in_half_the_tape_is_a_signal_one_rare_size_is_not() {
1399 let mut t = organic(100);
1400 for (i, tr) in t.iter_mut().enumerate() {
1401 match i % 10 {
1402 0 | 1 => tr.quantity = 498.11,
1403 2 | 3 => tr.quantity = 68.01,
1404 4 | 5 => tr.quantity = 500.0,
1405 _ => {}
1406 }
1407 }
1408 assert!(repeated_size(&t).is_none());
1410 let (f, idx) = few_sizes(&t).unwrap();
1411 assert_eq!(idx.len(), 60);
1412 assert!(
1413 matches!(&f, Finding::FewSizes { sizes, count: 60, of: 100 } if sizes == &[498.11, 68.01, 500.0]),
1414 "{f:?}"
1415 );
1416 let mut t = organic(100);
1418 for (i, tr) in t.iter_mut().enumerate() {
1419 if i % 20 < 9 {
1420 tr.quantity = if i % 2 == 0 { 498.11 } else { 68.01 };
1421 }
1422 }
1423 assert!(few_sizes(&t).is_none(), "{:?}", few_sizes(&t));
1424 assert!(few_sizes(&organic(200)).is_none());
1425 }
1426
1427 #[test]
1428 fn test_short_tapes_and_few_gaps_are_not_judged() {
1429 let short_tape = weex_tape(3);
1430 assert!(short_tape.len() < MIN_TRADES);
1431 assert!(few_sizes(&short_tape).is_none());
1432 assert!(cadence(&short_tape).is_none());
1433 let bunched: Vec<Trade> = (0..60u64)
1435 .map(|i| trade((i % 10) * 45_000, 1.0, 5.0 + i as f64, TradeSide::Buy))
1436 .collect();
1437 assert!(cadence(&bunched).is_none());
1438 let same: Vec<Trade> = (0..40)
1440 .map(|i| trade(7, 1.0, 5.0 + i as f64, TradeSide::Buy))
1441 .collect();
1442 assert!(metronome(&same).is_none());
1443 }
1444
1445 #[test]
1446 fn test_steps_read_to_two_significant_digits() {
1447 assert_eq!(step(0.010002), "0.01");
1448 assert_eq!(step(0.123), "0.12");
1449 assert_eq!(step(0.5), "0.5");
1450 assert_eq!(step(12.3), "12");
1451 assert_eq!(step(0.0), "0");
1452 }
1453
1454 #[test]
1455 fn test_bursts_at_one_interval_are_a_cadence_but_not_a_metronome() {
1456 let t = weex_tape(30);
1457 assert!(metronome(&t).is_none());
1458 let Some(Finding::Cadence { gap_ms, share, .. }) = cadence(&t) else {
1459 panic!("{:?}", cadence(&t))
1460 };
1461 assert!((gap_ms - 45_000.0).abs() < 50.0, "{gap_ms}");
1462 assert!(share > 0.85, "{share}");
1463 assert!(cadence(&organic(300)).is_none());
1464 }
1465
1466 #[test]
1467 fn test_random_gaps_in_whole_seconds_are_not_a_cadence() {
1468 let mut ms = 0;
1470 let t: Vec<Trade> = (0..400u64)
1471 .map(|i| {
1472 ms += 1_000 * (1 + (i * 7919 + i * i * 31) % 40);
1473 trade(ms, 1.0, 5.0 + i as f64, TradeSide::Buy)
1474 })
1475 .collect();
1476 assert!(cadence(&t).is_none(), "{:?}", cadence(&t));
1477 let fast: Vec<Trade> = (0..100u64)
1479 .map(|i| {
1480 trade(
1481 i * 1_000 + (i % 2) * 2_000,
1482 1.0,
1483 5.0 + i as f64,
1484 TradeSide::Buy,
1485 )
1486 })
1487 .collect();
1488 assert!(cadence(&fast).is_none(), "{:?}", cadence(&fast));
1489 }
1490
1491 #[test]
1492 fn test_sizes_that_fall_or_rise_level_by_level_are_a_shaped_ladder() {
1493 let (bids, asks) = weex_book();
1494 let found = ladders(&bids, &asks);
1495 assert_eq!(found.len(), 2, "{found:?}");
1496 for f in &found {
1497 assert!(
1498 matches!(
1499 f,
1500 Finding::Ladder {
1501 levels: 9,
1502 shape: LadderShape::Falling,
1503 ..
1504 }
1505 ),
1506 "{f:?}"
1507 );
1508 }
1509 let rising: Vec<_> = (0..10)
1510 .map(|i| level(0.9999 - i as f64 * 0.0001, 100.0 + i as f64 * 25.0))
1511 .collect();
1512 let found = ladders(&rising, &[]);
1513 assert!(
1514 matches!(
1515 found[..],
1516 [Finding::Ladder {
1517 levels: 10,
1518 shape: LadderShape::Rising,
1519 ..
1520 }]
1521 ),
1522 "{found:?}"
1523 );
1524 assert!(
1525 found[0].describe().contains("rising 100 → 325"),
1526 "{}",
1527 found[0].describe()
1528 );
1529 let zigzag: Vec<_> = [
1531 100.0, 200.0, 150.0, 300.0, 250.0, 400.0, 350.0, 500.0, 450.0,
1532 ]
1533 .iter()
1534 .enumerate()
1535 .map(|(i, &q)| level(1.0001 + i as f64 * 0.0001, q))
1536 .collect();
1537 assert!(ladders(&[], &zigzag).is_empty());
1538 }
1539
1540 fn sawtooth(samples: usize, every: usize) -> Vec<(i64, f64)> {
1543 (0..samples)
1544 .map(|i| {
1545 let into = i % every;
1546 let depth = (3_300.0 - into as f64 * 250.0).max(500.0);
1547 (i as i64 * 30_000 + (i as i64 * 7) % 400, depth)
1548 })
1549 .collect()
1550 }
1551
1552 #[test]
1553 fn test_a_book_refilled_on_a_timer_is_a_depth_cycle() {
1554 let c = depth_cycle(&sawtooth(110, 13)).unwrap();
1556 assert_eq!(c.cycles, 7);
1557 assert!((c.period_ms - 390_000.0).abs() < 1_000.0, "{}", c.period_ms);
1558 assert!(c.cv < 0.01, "{}", c.cv);
1559 assert!(depth_cycle(&sawtooth(30, 13)).is_none());
1561 }
1562
1563 #[test]
1564 fn test_irregular_refills_and_noise_are_not_a_depth_cycle() {
1565 let mut pts = Vec::new();
1567 let mut t = 0;
1568 for (k, len) in [5, 19, 7, 23, 6, 15, 31].into_iter().enumerate() {
1569 for j in 0..len {
1570 pts.push((t, 3_000.0 * 0.85f64.powi(j) + k as f64));
1571 t += 30_000;
1572 }
1573 }
1574 assert!(depth_cycle(&pts).is_none(), "{:?}", depth_cycle(&pts));
1575 let flip: Vec<(i64, f64)> = (0..60)
1577 .map(|i| (i * 30_000, if i % 2 == 0 { 1_000.0 } else { 2_000.0 }))
1578 .collect();
1579 assert!(depth_cycle(&flip).is_none());
1580 let grow: Vec<(i64, f64)> = (0..100)
1582 .map(|i| {
1583 let into = (i % 10) as f64;
1584 (
1585 i * 30_000,
1586 if into == 0.0 {
1587 5_000.0
1588 } else {
1589 1_000.0 + into * 100.0
1590 },
1591 )
1592 })
1593 .collect();
1594 assert!(depth_cycle(&grow).is_none(), "{:?}", depth_cycle(&grow));
1595 let flat: Vec<(i64, f64)> = (0..100).map(|i| (i * 30_000, 2_000.0)).collect();
1597 assert!(depth_cycle(&flat).is_none());
1598 assert!(depth_cycle(&[]).is_none());
1599 }
1600
1601 fn weex_book_oct26() -> (Vec<OrderBookLevel>, Vec<OrderBookLevel>) {
1605 let bids = [
1606 (0.9999, 389.6396),
1607 (0.9998, 560.112),
1608 (0.9997, 460.138),
1609 (0.9996, 370.148),
1610 (0.9995, 380.19),
1611 (0.9994, 340.2041),
1612 (0.9993, 310.2171),
1613 (0.9992, 230.1841),
1614 (0.9991, 160.1441),
1615 (0.9990, 110.1101),
1616 (0.0022, 3636.3636),
1617 ];
1618 let asks = [
1619 (1.0001, 2379.762),
1620 (1.0002, 1439.712),
1621 (1.0003, 1189.6431),
1622 (1.0004, 1009.5962),
1623 (1.0005, 989.5052),
1624 (1.0006, 919.4483),
1625 (1.0007, 769.4614),
1626 (1.0008, 579.5363),
1627 (1.0009, 399.6403),
1628 (1.0010, 309.6903),
1629 ];
1630 let side = |l: &[(f64, f64)]| l.iter().map(|&(p, q)| level(p, q)).collect();
1631 (side(&bids), side(&asks))
1632 }
1633
1634 fn jittered_tape(n: u64) -> Vec<Trade> {
1638 let mut ms = 1_000_000;
1639 (0..n)
1640 .map(|i| {
1641 ms += 30_000 + (i * 7_919 + i * i * 13) % 30_000;
1642 let side = if (i / 9) % 2 == 0 {
1643 TradeSide::Buy
1644 } else {
1645 TradeSide::Sell
1646 };
1647 trade(
1648 ms,
1649 1.0001,
1650 5.0 + (i * 37 % 431) as f64 + i as f64 / 100.0,
1651 side,
1652 )
1653 })
1654 .collect()
1655 }
1656
1657 #[test]
1658 fn test_the_oct26_weex_bot_is_caught_by_timer_round_notionals_and_ladders() {
1659 let tape = jittered_tape(500);
1660 let (bids, asks) = weex_book_oct26();
1661 let p = detect(Ok(&tape), Some((&bids, &asks)));
1662 let crate::market::types::HealthCheck::Warn(_) = p.check() else {
1663 panic!("{:?}", p.check())
1664 };
1665 let m = p.evidence().join("\n");
1667 assert!(m.contains("gaps between trades are 3"), "{m}");
1668 assert!(m.contains("Jittered timer: 90% of"), "{m}");
1669 assert!(
1670 m.contains("Bid ladder: 9 levels falling 560.112 → 110.1101"),
1671 "{m}"
1672 );
1673 assert!(
1674 m.contains("Ask ladder: 10 levels falling 2380 → 309.6903"),
1675 "{m}"
1676 );
1677 assert!(
1678 m.contains(
1679 "Bid round notionals: 9 of 10 levels are whole quote amounts (560, 460, 370, …)"
1680 ),
1681 "{m}"
1682 );
1683 assert!(
1684 m.contains(
1685 "Ask round notionals: 10 of 10 levels are whole quote amounts (2380, 1440, 1190, …)"
1686 ),
1687 "{m}"
1688 );
1689 assert!(!m.contains("look bot-made"), "{m}");
1691 assert_eq!(
1692 p.bot_like_volume_pct.map(|v| v.to_string()),
1693 Some("0".to_string())
1694 );
1695 }
1696
1697 #[test]
1698 fn test_a_jittered_timer_is_found_and_real_order_flow_is_not() {
1699 let Some(Finding::Timer {
1700 low_ms,
1701 median_ms,
1702 high_ms,
1703 cv,
1704 ..
1705 }) = timer(&jittered_tape(400))
1706 else {
1707 panic!("{:?}", timer(&jittered_tape(400)))
1708 };
1709 assert!(
1710 low_ms >= 30_000.0 && high_ms <= 60_000.0,
1711 "{low_ms} {high_ms}"
1712 );
1713 assert!((40_000.0..50_000.0).contains(&median_ms), "{median_ms}");
1714 assert!(cv < 0.25, "{cv}");
1715 let mut ms = 0;
1718 let poisson: Vec<Trade> = (0..400u64)
1719 .map(|i| {
1720 ms += [50, 120, 400, 900, 2_500, 6_000, 15_000, 40_000, 110_000]
1721 [(i * 7 % 9) as usize];
1722 trade(ms, 1.0, 5.0 + i as f64, TradeSide::Buy)
1723 })
1724 .collect();
1725 assert!(timer(&poisson).is_none(), "{:?}", timer(&poisson));
1726 assert!(timer(&organic(300)).is_none());
1727 let fast: Vec<Trade> = (0..100u64)
1729 .map(|i| {
1730 trade(
1731 i * 2_000 + (i % 3) * 500,
1732 1.0,
1733 5.0 + i as f64,
1734 TradeSide::Buy,
1735 )
1736 })
1737 .collect();
1738 assert!(timer(&fast).is_none(), "{:?}", timer(&fast));
1739 }
1740
1741 #[test]
1742 fn test_round_notionals_need_most_levels_and_sizes_that_are_not_whole() {
1743 let (bids, asks) = weex_book_oct26();
1744 assert_eq!(round_notionals(&bids, &asks).len(), 2);
1745 let six: Vec<_> = (0..10)
1747 .map(|i| {
1748 let price = 0.9999 - i as f64 * 0.0001;
1749 let qty = if i < 6 {
1750 (300.0 + i as f64 * 10.0) / price
1751 } else {
1752 123.4567 + i as f64
1753 };
1754 level(price, qty)
1755 })
1756 .collect();
1757 assert!(round_notionals(&six, &[]).is_empty());
1758 let people: Vec<_> = (0..10)
1760 .map(|i| level(1.0, 100.0 + i as f64 * 50.0))
1761 .collect();
1762 assert!(round_notionals(&[], &people).is_empty());
1763 }
1764
1765 #[test]
1766 fn test_one_small_bump_does_not_end_a_shaped_ladder_but_a_big_one_does() {
1767 let (bids, _) = weex_book_oct26();
1768 let found = ladders(&bids, &[]);
1769 assert!(
1770 matches!(
1771 found[..],
1772 [Finding::Ladder {
1773 levels: 9,
1774 shape: LadderShape::Falling,
1775 ..
1776 }]
1777 ),
1778 "{found:?}"
1779 );
1780 let sizes = [
1782 900.0, 800.0, 700.0, 600.0, 660.0, 500.0, 400.0, 300.0, 200.0, 100.0,
1783 ];
1784 let bumped: Vec<_> = sizes
1785 .iter()
1786 .enumerate()
1787 .map(|(i, &q)| level(0.9999 - i as f64 * 0.0001, q))
1788 .collect();
1789 assert!(
1790 ladders(&bumped, &[]).is_empty(),
1791 "{:?}",
1792 ladders(&bumped, &[])
1793 );
1794 }
1795
1796 #[test]
1797 fn test_the_check_names_the_signals_and_the_section_lists_them() {
1798 let tape = jittered_tape(500);
1799 let (bids, asks) = weex_book_oct26();
1800 let p = detect(Ok(&tape), Some((&bids, &asks)));
1801 assert_eq!(
1802 p.check().message(),
1803 "Bot patterns: 5 signs of market-making bots (jittered timer, bid ladder, ask ladder, bid round notionals, ask round notionals)"
1804 );
1805 let text = plain(&p.format_text());
1806 let lines: Vec<&str> = text.lines().collect();
1807 assert!(
1808 lines
1809 .iter()
1810 .take(2)
1811 .any(|l| l.contains("Bot Patterns · likely market-making bots")),
1812 "{text}"
1813 );
1814 let at = |needle: &str| {
1816 lines
1817 .iter()
1818 .position(|l| l.contains(needle))
1819 .unwrap_or_else(|| panic!("no {needle} in\n{text}"))
1820 };
1821 assert!(at("Trades") < at("! Jittered timer"), "{text}");
1822 assert!(at("! Jittered timer") < at("Book"), "{text}");
1823 assert!(at("Book") < at("! Bid ladder"), "{text}");
1824 assert!(at("! Bid ladder") < at("! Ask round notionals"), "{text}");
1825 assert!(lines[at("Trades")].contains("500 sampled"), "{text}");
1826 let column = |needle: &str| {
1828 let l = lines[at(needle)];
1829 l.find(needle).unwrap()
1830 + needle.len()
1831 + l[l.find(needle).unwrap() + needle.len()..].len()
1832 - l[l.find(needle).unwrap() + needle.len()..]
1833 .trim_start()
1834 .len()
1835 };
1836 assert_eq!(
1837 column("! Jittered timer"),
1838 column("! Ask round notionals"),
1839 "{text}"
1840 );
1841 assert!(!text.contains("Bot-like share"), "{text}");
1843 }
1844
1845 #[test]
1846 fn test_a_share_line_follows_trade_signals_and_a_book_only_finding_says_why_no_trades() {
1847 let mut t = organic(100);
1848 for tr in t.iter_mut().step_by(2) {
1849 tr.quantity = 1000.0;
1850 }
1851 let text = plain(&detect(Ok(&t), None).format_text());
1852 assert!(text.contains("! Repeated size"), "{text}");
1853 assert!(text.contains("Bot-like share"), "{text}");
1854 assert!(text.contains("50% of sampled trades ("), "{text}");
1855 assert!(
1856 !text.contains("Book "),
1857 "no book group without book signals: {text}"
1858 );
1859 let (bids, asks) = weex_book_oct26();
1860 let book_only = detect(Err("the venue has no trade endpoint"), Some((&bids, &asks)));
1861 let text = plain(&book_only.format_text());
1862 assert!(
1863 text.contains("not checked (the venue has no trade endpoint)"),
1864 "{text}"
1865 );
1866 assert!(text.contains("! Bid ladder"), "{text}");
1867 }
1868}