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_BAND_SHARE: f64 = 0.70;
66
67pub const TIMER_BAND: f64 = 0.50;
69
70pub const TIMER_MIN_FLOOR: f64 = 0.40;
73
74pub const CADENCE_TOLERANCE: f64 = 0.02;
76
77pub const CADENCE_SHARE: f64 = 0.30;
80
81pub const CADENCE_MIN_GAP_MS: f64 = 5_000.0;
84
85pub const PING_PONG_WINDOW_MS: u64 = 5_000;
87
88pub const PING_PONG_PRICE_TOLERANCE_PCT: f64 = 0.05;
90
91pub const PING_PONG_SHARE: f64 = 0.20;
94
95pub const MIN_PING_PONG_PAIRS: usize = 5;
97
98pub const LADDER_MIN_LEVELS: usize = 8;
100
101pub const LADDER_SIZE_TOLERANCE: f64 = 0.01;
103
104pub const LADDER_SHAPE_SLACK: f64 = 0.05;
107
108pub const ROUND_LEVELS: usize = 10;
110
111pub const ROUND_MIN_LEVELS: usize = 8;
114
115const ROUND_TOLERANCE: f64 = 0.005;
117
118pub const LADDER_STEP_TOLERANCE: f64 = 0.05;
120
121pub const CYCLE_JUMP: f64 = 0.25;
123
124pub const CYCLE_MIN: usize = 3;
126
127pub const CYCLE_MIN_RUNS: usize = 3;
130
131pub const CYCLE_MAX_CV: f64 = 0.20;
134
135pub const CYCLE_FALLING_SHARE: f64 = 0.60;
138
139pub const LADDER_DEPTH: usize = 25;
141
142const SIZE_DECIMALS: f64 = 1e8;
145
146#[derive(Debug, Clone, PartialEq, Serialize)]
148#[serde(tag = "kind", rename_all = "snake_case")]
149pub enum Finding {
150 RepeatedSize { size: f64, count: usize, of: usize },
152 FewSizes {
155 sizes: Vec<f64>,
156 count: usize,
157 of: usize,
158 },
159 Metronome {
163 mean_gap_ms: f64,
164 cv: f64,
165 gaps: usize,
166 dominant_size: Option<f64>,
167 },
168 Cadence {
171 gap_ms: f64,
172 share: f64,
173 gaps: usize,
174 },
175 Timer {
180 low_ms: f64,
181 median_ms: f64,
182 high_ms: f64,
183 cv: f64,
184 gaps: usize,
185 share: f64,
186 pauses: usize,
187 },
188 PingPong { pairs: usize, share: f64 },
191 Ladder {
195 side: BookSide,
196 levels: usize,
197 shape: LadderShape,
198 size: f64,
199 last_size: f64,
200 step_pct: f64,
201 },
202 RoundNotional {
205 side: BookSide,
206 levels: usize,
207 of: usize,
208 examples: Vec<f64>,
209 },
210 DepthCycle {
214 side: BookSide,
215 period_ms: f64,
216 cycles: usize,
217 cv: f64,
218 runs: usize,
219 },
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
224#[serde(rename_all = "snake_case")]
225pub enum LadderShape {
226 Even,
228 Falling,
231 Rising,
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize)]
239pub struct DepthCycle {
240 pub period_ms: f64,
242 pub cycles: usize,
244 pub cv: f64,
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize)]
250pub struct BotPatterns {
251 pub trades_sampled: usize,
253 pub tape_note: Option<String>,
255 pub findings: Vec<Finding>,
257 pub bot_like_pct: Option<f64>,
260 pub bot_like_volume_pct: Option<f64>,
263 pub book_checked: bool,
265}
266
267fn size_key(quantity: f64) -> i64 {
269 (quantity * SIZE_DECIMALS).round() as i64
270}
271
272fn sizes_by_count(trades: &[Trade]) -> Vec<(f64, Vec<usize>)> {
274 let mut by_size: std::collections::HashMap<i64, Vec<usize>> = Default::default();
275 for (i, t) in trades.iter().enumerate() {
276 if t.quantity > 0.0 {
277 by_size.entry(size_key(t.quantity)).or_default().push(i);
278 }
279 }
280 let mut sizes: Vec<Vec<usize>> = by_size.into_values().collect();
281 sizes.sort_by(|a, b| b.len().cmp(&a.len()).then(a[0].cmp(&b[0])));
284 sizes
285 .into_iter()
286 .map(|idx| (trades[idx[0]].quantity, idx))
287 .collect()
288}
289
290fn dominant_size(trades: &[Trade]) -> Option<(f64, Vec<usize>)> {
292 sizes_by_count(trades).into_iter().next()
293}
294
295pub fn repeated_size(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
298 if trades.len() < MIN_TRADES {
299 return None;
300 }
301 let (size, idx) = dominant_size(trades)?;
302 (idx.len() as f64 / trades.len() as f64 >= REPEATED_SIZE_SHARE).then_some((
303 Finding::RepeatedSize {
304 size,
305 count: idx.len(),
306 of: trades.len(),
307 },
308 idx,
309 ))
310}
311
312pub fn few_sizes(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
316 if trades.len() < MIN_TRADES {
317 return None;
318 }
319 let n = trades.len() as f64;
320 let top: Vec<(f64, Vec<usize>)> = sizes_by_count(trades)
321 .into_iter()
322 .take(FEW_SIZES)
323 .filter(|(_, idx)| idx.len() as f64 / n >= FEW_SIZES_MIN_SHARE)
324 .collect();
325 let idx: Vec<usize> = top
326 .iter()
327 .flat_map(|(_, idx)| idx.iter().copied())
328 .collect();
329 (top.len() > 1 && idx.len() as f64 / n >= FEW_SIZES_SHARE).then(|| {
330 (
331 Finding::FewSizes {
332 sizes: top.iter().map(|(s, _)| *s).collect(),
333 count: idx.len(),
334 of: trades.len(),
335 },
336 idx,
337 )
338 })
339}
340
341fn event_gaps(mut times: Vec<u64>) -> Vec<f64> {
344 times.sort_unstable();
345 times.dedup();
346 times.windows(2).map(|w| (w[1] - w[0]) as f64).collect()
347}
348
349fn mean_cv(gaps: &[f64]) -> Option<(f64, f64)> {
351 let n = gaps.len() as f64;
352 let mean = gaps.iter().sum::<f64>() / n;
353 if mean <= 0.0 {
354 return None;
355 }
356 let var = gaps.iter().map(|g| (g - mean).powi(2)).sum::<f64>() / n;
357 Some((mean, var.sqrt() / mean))
358}
359
360pub fn metronome(trades: &[Trade]) -> Option<Finding> {
363 if trades.len() < MIN_TRADES {
364 return None;
365 }
366 let judge = |times: Vec<u64>, dominant: Option<f64>| {
367 let gaps = event_gaps(times);
368 if gaps.len() < MIN_GAPS {
369 return None;
370 }
371 let (mean, cv) = mean_cv(&gaps)?;
372 (cv < METRONOME_MAX_CV).then_some(Finding::Metronome {
373 mean_gap_ms: mean,
374 cv,
375 gaps: gaps.len(),
376 dominant_size: dominant,
377 })
378 };
379 judge(trades.iter().map(|t| t.timestamp_ms).collect(), None).or_else(|| {
380 let (size, idx) = dominant_size(trades)?;
381 judge(
382 idx.iter().map(|&i| trades[i].timestamp_ms).collect(),
383 Some(size),
384 )
385 })
386}
387
388pub fn cadence(trades: &[Trade]) -> Option<Finding> {
392 if trades.len() < MIN_TRADES {
393 return None;
394 }
395 let mut gaps = event_gaps(trades.iter().map(|t| t.timestamp_ms).collect());
396 if gaps.len() < MIN_GAPS {
397 return None;
398 }
399 gaps.sort_by(f64::total_cmp);
400 let (mut lo, mut hi) = (0, 0);
403 let mut best: Option<(usize, usize)> = None; for (i, &g) in gaps.iter().enumerate() {
405 if g < CADENCE_MIN_GAP_MS {
406 continue;
407 }
408 while gaps[lo] < g * (1.0 - CADENCE_TOLERANCE) {
409 lo += 1;
410 }
411 hi = hi.max(i);
412 while hi + 1 < gaps.len() && gaps[hi + 1] <= g * (1.0 + CADENCE_TOLERANCE) {
413 hi += 1;
414 }
415 let count = hi + 1 - lo;
416 if best.is_none_or(|(c, _)| count > c) {
417 best = Some((count, i));
418 }
419 }
420 let (count, i) = best?;
421 let share = count as f64 / gaps.len() as f64;
422 (share >= CADENCE_SHARE).then_some(Finding::Cadence {
423 gap_ms: gaps[i],
424 share,
425 gaps: gaps.len(),
426 })
427}
428
429pub fn depth_cycle(points: &[(i64, f64)]) -> Option<DepthCycle> {
435 let refills: Vec<usize> = (1..points.len())
436 .filter(|&i| {
437 let (a, b) = (points[i - 1].1, points[i].1);
438 a > 0.0 && b >= a * (1.0 + CYCLE_JUMP)
439 })
440 .collect();
441 if refills.len() < CYCLE_MIN + 1 || refills.windows(2).any(|w| w[1] - w[0] < CYCLE_MIN_RUNS) {
442 return None;
443 }
444 let periods: Vec<f64> = refills
445 .windows(2)
446 .map(|w| (points[w[1]].0 - points[w[0]].0) as f64)
447 .collect();
448 let (mean, cv) = mean_cv(&periods)?;
449 if cv >= CYCLE_MAX_CV {
450 return None;
451 }
452 let steps: Vec<bool> = refills
453 .windows(2)
454 .flat_map(|w| w[0] + 1..w[1])
455 .map(|i| points[i].1 <= points[i - 1].1 * (1.0 + LADDER_SIZE_TOLERANCE))
456 .collect();
457 let falling = steps.iter().filter(|f| **f).count() as f64 / steps.len() as f64;
458 (falling >= CYCLE_FALLING_SHARE).then_some(DepthCycle {
459 period_ms: mean,
460 cycles: refills.len() - 1,
461 cv,
462 })
463}
464
465pub fn timer(trades: &[Trade]) -> Option<Finding> {
470 if trades.len() < MIN_TRADES {
471 return None;
472 }
473 let mut gaps = event_gaps(trades.iter().map(|t| t.timestamp_ms).collect());
474 if gaps.len() < MIN_GAPS {
475 return None;
476 }
477 let (_, cv) = mean_cv(&gaps)?;
478 gaps.sort_by(f64::total_cmp);
479 let at = |q: f64| gaps[((gaps.len() - 1) as f64 * q).round() as usize];
480 let (floor, median) = (at(0.05), at(0.5));
481 let band: Vec<f64> = gaps
482 .iter()
483 .copied()
484 .filter(|g| (g - median).abs() <= median * TIMER_BAND)
485 .collect();
486 let share = band.len() as f64 / gaps.len() as f64;
487 (median >= CADENCE_MIN_GAP_MS && share >= TIMER_BAND_SHARE && floor >= median * TIMER_MIN_FLOOR)
488 .then(|| Finding::Timer {
489 low_ms: band[0],
491 median_ms: median,
492 high_ms: band[band.len() - 1],
493 cv,
494 gaps: gaps.len(),
495 share,
496 pauses: gaps
497 .iter()
498 .filter(|g| **g > median * (1.0 + TIMER_BAND) * 4.0)
499 .count(),
500 })
501}
502
503pub fn ping_pong(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
507 if trades.len() < MIN_TRADES {
508 return None;
509 }
510 let mut order: Vec<usize> = (0..trades.len()).collect();
511 order.sort_by_key(|&i| trades[i].timestamp_ms);
512 let mut paired = vec![false; trades.len()];
513 let mut members = Vec::new();
514 for (pos, &i) in order.iter().enumerate() {
515 if paired[i] {
516 continue;
517 }
518 let a = &trades[i];
519 for &j in &order[pos + 1..] {
520 let b = &trades[j];
521 if b.timestamp_ms - a.timestamp_ms > PING_PONG_WINDOW_MS {
522 break;
523 }
524 let opposite = matches!(
525 (&a.side, &b.side),
526 (TradeSide::Buy, TradeSide::Sell) | (TradeSide::Sell, TradeSide::Buy)
527 );
528 let near = a.price > 0.0
529 && ((b.price - a.price).abs() / a.price * 100.0) <= PING_PONG_PRICE_TOLERANCE_PCT;
530 if !paired[j] && opposite && near && size_key(a.quantity) == size_key(b.quantity) {
531 paired[i] = true;
532 paired[j] = true;
533 members.extend([i, j]);
534 break;
535 }
536 }
537 }
538 let pairs = members.len() / 2;
539 let share = members.len() as f64 / trades.len() as f64;
540 (pairs >= MIN_PING_PONG_PAIRS && share >= PING_PONG_SHARE)
541 .then_some((Finding::PingPong { pairs, share }, members))
542}
543
544fn size_direction(a: f64, b: f64) -> i8 {
547 if (b - a).abs() <= a * LADDER_SIZE_TOLERANCE {
548 0
549 } else if b > a {
550 1
551 } else {
552 -1
553 }
554}
555
556fn ladder_side(levels: &[OrderBookLevel], side: BookSide) -> Option<Finding> {
559 let levels: Vec<&OrderBookLevel> = levels
560 .iter()
561 .filter(|l| l.price > 0.0 && l.quantity > 0.0)
562 .take(LADDER_DEPTH)
563 .collect();
564 let mut best: Option<(usize, usize, i8)> = None; let mut start = 0;
566 while start + 1 < levels.len() {
567 let step = (levels[start + 1].price - levels[start].price).abs();
568 let mut dir = 0;
569 let mut end = start + 1;
570 while end < levels.len() {
571 let this_step = (levels[end].price - levels[end - 1].price).abs();
572 let same_step = step > 0.0 && (this_step - step).abs() <= step * LADDER_STEP_TOLERANCE;
573 let (a, b) = (levels[end - 1].quantity, levels[end].quantity);
574 let mut d = size_direction(a, b);
575 if d != 0 && dir != 0 && d != dir && (b - a).abs() <= a * LADDER_SHAPE_SLACK {
577 d = 0;
578 }
579 if !same_step || (d != 0 && dir != 0 && d != dir) {
580 break;
581 }
582 if d != 0 {
583 dir = d;
584 }
585 end += 1;
586 }
587 let len = end - start;
588 if best.is_none_or(|(_, l, _)| len > l) {
589 best = Some((start, len, dir));
590 }
591 start = (end - 1).max(start + 1);
594 }
595 let (s, len, dir) = best?;
596 if len < LADDER_MIN_LEVELS {
597 return None;
598 }
599 let (first, last) = (levels[s].quantity, levels[s + len - 1].quantity);
600 let even = levels[s..s + len]
601 .iter()
602 .all(|l| size_direction(first, l.quantity) == 0);
603 let shape = match dir {
604 _ if even => LadderShape::Even,
605 1 => LadderShape::Rising,
606 _ => LadderShape::Falling,
607 };
608 let step = (levels[s + 1].price - levels[s].price).abs();
609 Some(Finding::Ladder {
610 side,
611 levels: len,
612 shape,
613 size: first,
614 last_size: last,
615 step_pct: step / levels[s].price * 100.0,
616 })
617}
618
619fn round_notional_side(levels: &[OrderBookLevel], side: BookSide) -> Option<Finding> {
623 let top: Vec<&OrderBookLevel> = levels
624 .iter()
625 .filter(|l| l.price > 0.0 && l.quantity > 0.0)
626 .take(ROUND_LEVELS)
627 .collect();
628 let round: Vec<f64> = top
629 .iter()
630 .filter_map(|l| {
631 let notional = l.price * l.quantity;
632 let whole = (notional - notional.round()).abs() < ROUND_TOLERANCE;
633 let whole_size = (l.quantity - l.quantity.round()).abs() < 1e-9;
634 (whole && !whole_size && notional.round() > 0.0).then_some(notional.round())
635 })
636 .collect();
637 (round.len() >= ROUND_MIN_LEVELS).then(|| Finding::RoundNotional {
638 side,
639 levels: round.len(),
640 of: top.len(),
641 examples: round.into_iter().take(3).collect(),
642 })
643}
644
645pub fn round_notionals(bids: &[OrderBookLevel], asks: &[OrderBookLevel]) -> Vec<Finding> {
647 [
648 round_notional_side(bids, BookSide::Bid),
649 round_notional_side(asks, BookSide::Ask),
650 ]
651 .into_iter()
652 .flatten()
653 .collect()
654}
655
656pub fn ladders(bids: &[OrderBookLevel], asks: &[OrderBookLevel]) -> Vec<Finding> {
658 [
659 ladder_side(bids, BookSide::Bid),
660 ladder_side(asks, BookSide::Ask),
661 ]
662 .into_iter()
663 .flatten()
664 .collect()
665}
666
667pub fn detect(
671 trades: Result<&[Trade], &str>,
672 book: Option<(&[OrderBookLevel], &[OrderBookLevel])>,
673) -> BotPatterns {
674 let mut findings = Vec::new();
675 let mut tape_note = None;
676 let mut bot_like_pct = None;
677 let mut bot_like_volume_pct = None;
678 let mut sampled = 0;
679 match trades {
680 Err(why) => tape_note = Some(why.to_string()),
681 Ok(t) if t.len() < MIN_TRADES => {
682 sampled = t.len();
683 tape_note = Some(format!(
684 "only {} recent trades; the tape signals need {MIN_TRADES}",
685 t.len()
686 ));
687 }
688 Ok(t) => {
689 sampled = t.len();
690 let mut covered = vec![false; t.len()];
691 if let Some((f, idx)) = repeated_size(t).or_else(|| few_sizes(t)) {
693 idx.iter().for_each(|&i| covered[i] = true);
694 findings.push(f);
695 }
696 if let Some(f) = metronome(t).or_else(|| cadence(t)).or_else(|| timer(t)) {
699 findings.push(f);
700 }
701 if let Some((f, idx)) = ping_pong(t) {
702 idx.iter().for_each(|&i| covered[i] = true);
703 findings.push(f);
704 }
705 let n = covered.iter().filter(|c| **c).count();
706 bot_like_pct = Some(n as f64 / t.len() as f64 * 100.0);
707 let value = |tr: &Trade| tr.quote_quantity.unwrap_or(tr.price * tr.quantity);
708 let total: f64 = t.iter().map(value).sum();
709 let bot: f64 = t
710 .iter()
711 .zip(&covered)
712 .filter(|(_, c)| **c)
713 .map(|(tr, _)| value(tr))
714 .sum();
715 bot_like_volume_pct = (total > 0.0).then(|| bot / total * 100.0 + 0.0);
717 }
718 }
719 if let Some((bids, asks)) = book {
720 findings.extend(ladders(bids, asks));
721 findings.extend(round_notionals(bids, asks));
722 }
723 BotPatterns {
724 trades_sampled: sampled,
725 tape_note,
726 findings,
727 bot_like_pct,
728 bot_like_volume_pct,
729 book_checked: book.is_some(),
730 }
731}
732
733fn short(v: f64) -> String {
735 let s = format!("{:.6}", v);
736 let s = s.trim_end_matches('0').trim_end_matches('.');
737 if v.abs() >= 1000.0 {
738 format!("{v:.0}")
739 } else {
740 s.to_string()
741 }
742}
743
744fn step(pct: f64) -> String {
747 if pct <= 0.0 {
748 return short(pct);
749 }
750 let decimals = (1 - pct.log10().floor() as i32).max(0) as usize;
751 let s = format!("{pct:.decimals$}");
752 if s.contains('.') {
753 s.trim_end_matches('0').trim_end_matches('.').to_string()
754 } else {
755 s
756 }
757}
758
759type Belongs = fn(&Finding) -> bool;
761
762fn span(ms: f64) -> String {
764 let s = (ms / 1000.0).round().max(0.0) as u64;
765 match s {
766 0..60 => format!("{s}s"),
767 60..3600 => format!("{}m {:02}s", s / 60, s % 60),
768 _ => format!("{}h {:02}m", s / 3600, s % 3600 / 60),
769 }
770}
771
772impl Finding {
773 pub fn label(&self) -> String {
775 let side = |side: &BookSide| match side {
776 BookSide::Bid => "Bid",
777 BookSide::Ask => "Ask",
778 };
779 match self {
780 Finding::RepeatedSize { .. } => "Repeated size".to_string(),
781 Finding::FewSizes { .. } => "Few sizes".to_string(),
782 Finding::Metronome { .. } => "Metronome".to_string(),
783 Finding::Cadence { .. } => "Cadence".to_string(),
784 Finding::Timer { .. } => "Jittered timer".to_string(),
785 Finding::PingPong { .. } => "Ping-pong".to_string(),
786 Finding::Ladder { side: s, .. } => format!("{} ladder", side(s)),
787 Finding::RoundNotional { side: s, .. } => format!("{} round notionals", side(s)),
788 Finding::DepthCycle { side: s, .. } => format!("{} depth cycle", side(s)),
789 }
790 }
791
792 pub fn in_book(&self) -> bool {
795 matches!(self, Finding::Ladder { .. } | Finding::RoundNotional { .. })
796 }
797
798 pub fn across_runs(&self) -> bool {
800 matches!(self, Finding::DepthCycle { .. })
801 }
802
803 pub fn detail(&self) -> String {
805 match self {
806 Finding::RepeatedSize { size, count, of } => format!(
807 "{:.0}% of {of} trades are exactly {}",
808 *count as f64 / *of as f64 * 100.0,
809 short(*size)
810 ),
811 Finding::Metronome {
812 mean_gap_ms,
813 cv,
814 gaps,
815 dominant_size,
816 } => {
817 let whose = dominant_size
818 .map(|s| format!("size-{} trades", short(s)))
819 .unwrap_or_else(|| "trades".to_string());
820 format!(
821 "{whose} every {:.1}s ±{:.0}% over {gaps} gaps",
822 mean_gap_ms / 1000.0,
823 cv * 100.0
824 )
825 }
826 Finding::FewSizes { sizes, count, of } => format!(
827 "{:.0}% of {of} trades are one of {} sizes: {}",
828 *count as f64 / *of as f64 * 100.0,
829 sizes.len(),
830 sizes
831 .iter()
832 .map(|s| short(*s))
833 .collect::<Vec<_>>()
834 .join(", ")
835 ),
836 Finding::Cadence {
837 gap_ms,
838 share,
839 gaps,
840 } => format!(
841 "{:.0}% of {gaps} gaps between trades are {:.1}s ±{:.0}%",
842 share * 100.0,
843 gap_ms / 1000.0,
844 CADENCE_TOLERANCE * 100.0
845 ),
846 Finding::Timer {
847 low_ms,
848 median_ms,
849 high_ms,
850 gaps,
851 share,
852 pauses,
853 ..
854 } => format!(
855 "{:.0}% of {gaps} gaps between trades are {:.0}–{:.0}s (median {:.0}s){}",
856 share * 100.0,
857 low_ms / 1000.0,
858 high_ms / 1000.0,
859 median_ms / 1000.0,
860 match pauses {
861 0 => String::new(),
862 1 => ", in bursts with 1 long pause".to_string(),
863 n => format!(", in bursts with {n} long pauses"),
864 }
865 ),
866 Finding::RoundNotional {
867 levels,
868 of,
869 examples,
870 ..
871 } => format!(
872 "{levels} of {of} levels are whole quote amounts ({}, …)",
873 examples
874 .iter()
875 .map(|v| short(*v))
876 .collect::<Vec<_>>()
877 .join(", ")
878 ),
879 Finding::PingPong { pairs, share } => format!(
880 "{pairs} buy/sell pairs of one size, seconds apart ({:.0}% of trades)",
881 share * 100.0
882 ),
883 Finding::Ladder {
884 levels,
885 shape,
886 size,
887 last_size,
888 step_pct,
889 ..
890 } => {
891 let sizes = match shape {
892 LadderShape::Even => format!("× {}", short(*size)),
893 LadderShape::Falling => {
894 format!("falling {} → {}", short(*size), short(*last_size))
895 }
896 LadderShape::Rising => {
897 format!("rising {} → {}", short(*size), short(*last_size))
898 }
899 };
900 format!("{levels} levels {sizes} at {}% steps", step(*step_pct))
901 }
902 Finding::DepthCycle {
903 period_ms, cycles, ..
904 } => format!(
905 "saw-tooth: drains, then refills every {} ({cycles} cycles)",
906 span(*period_ms)
907 ),
908 }
909 }
910
911 pub fn describe(&self) -> String {
913 format!("{}: {}", self.label(), self.detail())
914 }
915}
916
917impl BotPatterns {
918 pub fn share(&self) -> Option<String> {
921 let p = self.bot_like_pct.filter(|p| *p > 0.0)?;
922 Some(match self.bot_like_volume_pct {
923 Some(v) => format!("{p:.0}% of sampled trades ({v:.1}% of their volume) look bot-made"),
924 None => format!("{p:.0}% of sampled trades look bot-made"),
925 })
926 }
927
928 pub fn format_text(&self) -> String {
939 use crate::display::terminal as t;
940 let width = self
941 .findings
942 .iter()
943 .map(|f| f.label().chars().count())
944 .max()
945 .unwrap_or(0)
946 + 2;
947 let mut out = t::subsection_header("Bot Patterns · likely market-making bots");
948 out.push('\n');
949 let runs = self
950 .findings
951 .iter()
952 .find_map(|f| match f {
953 Finding::DepthCycle { runs, .. } => Some(*runs),
954 _ => None,
955 })
956 .unwrap_or(0);
957 let groups: [(&str, Belongs, String); 3] = [
958 (
959 "Trades",
960 |f| !f.in_book() && !f.across_runs(),
961 format!("{} sampled", self.trades_sampled),
962 ),
963 (
964 "Book",
965 Finding::in_book,
966 "top levels of each side".to_string(),
967 ),
968 (
969 "Runs",
970 Finding::across_runs,
971 format!("depth over {runs} runs"),
972 ),
973 ];
974 for (name, belongs, about) in groups {
975 let found: Vec<&Finding> = self.findings.iter().filter(|f| belongs(f)).collect();
976 if found.is_empty() {
977 continue;
978 }
979 out.push_str(&t::kv_row(name, &about));
980 out.push('\n');
981 for f in found {
982 out.push_str(&t::labelled_warn(&f.label(), width, &f.detail()));
983 out.push('\n');
984 }
985 }
986 if let Some(note) = self
987 .tape_note
988 .as_ref()
989 .filter(|_| self.findings.iter().all(|f| f.in_book() || f.across_runs()))
990 {
991 out.push_str(&t::kv_row("Trades", &format!("not checked ({note})")));
992 out.push('\n');
993 }
994 if let Some(share) = self.share() {
995 out.push_str(&t::kv_row("Bot-like share", &share));
996 out.push('\n');
997 }
998 out
999 }
1000
1001 pub fn evidence(&self) -> Vec<String> {
1004 let mut lines: Vec<String> = self.findings.iter().map(Finding::describe).collect();
1005 lines.extend(self.share());
1006 lines
1007 }
1008
1009 pub fn set_depth_cycles(&mut self, bid: &[(i64, f64)], ask: &[(i64, f64)]) {
1012 self.findings.retain(|f| !f.across_runs());
1013 for (side, points) in [(BookSide::Bid, bid), (BookSide::Ask, ask)] {
1014 if let Some(c) = depth_cycle(points) {
1015 self.findings.push(Finding::DepthCycle {
1016 side,
1017 period_ms: c.period_ms,
1018 cycles: c.cycles,
1019 cv: c.cv,
1020 runs: points.len(),
1021 });
1022 }
1023 }
1024 }
1025
1026 pub fn check(&self) -> super::types::HealthCheck {
1031 use super::types::HealthCheck;
1032 if !self.findings.is_empty() {
1033 let n = self.findings.len();
1034 let names: Vec<String> = self
1035 .findings
1036 .iter()
1037 .map(|f| f.label().to_lowercase())
1038 .collect();
1039 return HealthCheck::Warn(format!(
1040 "Bot patterns: {n} {} of market-making bots ({})",
1041 if n == 1 { "sign" } else { "signs" },
1042 names.join(", ")
1043 ));
1044 }
1045 let book = if self.book_checked {
1046 " or the book"
1047 } else {
1048 ""
1049 };
1050 match &self.tape_note {
1051 None => HealthCheck::Pass(format!(
1052 "No bot patterns in {} recent trades{book}",
1053 self.trades_sampled
1054 )),
1055 Some(why) if self.book_checked => HealthCheck::NotApplicable(format!(
1056 "Bot patterns: none in the book; trades not checked ({why})"
1057 )),
1058 Some(why) => HealthCheck::NotApplicable(format!("Bot patterns: not checked ({why})")),
1059 }
1060 }
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065 use super::*;
1066
1067 fn trade(ms: u64, price: f64, qty: f64, side: TradeSide) -> Trade {
1068 Trade {
1069 price,
1070 quantity: qty,
1071 quote_quantity: None,
1072 timestamp_ms: ms,
1073 side,
1074 id: None,
1075 }
1076 }
1077
1078 fn organic(n: usize) -> Vec<Trade> {
1080 let mut ms = 1_000_000;
1081 (0..n)
1082 .map(|i| {
1083 ms += 300 + (i as u64 * 7919) % 4_000;
1084 let qty = 10.0 + ((i * 37) % 97) as f64 * 1.37;
1085 let side = if (i * 13) % 3 == 0 {
1086 TradeSide::Buy
1087 } else {
1088 TradeSide::Sell
1089 };
1090 trade(ms, 1.0 + ((i * 11) % 7) as f64 * 0.0001, qty, side)
1091 })
1092 .collect()
1093 }
1094
1095 fn plain(text: &str) -> String {
1097 let mut out = String::new();
1098 let mut chars = text.chars();
1099 while let Some(c) = chars.next() {
1100 if c == '' {
1101 for c in chars.by_ref() {
1102 if c == 'm' {
1103 break;
1104 }
1105 }
1106 } else {
1107 out.push(c);
1108 }
1109 }
1110 out
1111 }
1112
1113 fn level(price: f64, quantity: f64) -> OrderBookLevel {
1114 OrderBookLevel { price, quantity }
1115 }
1116
1117 #[test]
1118 fn test_an_organic_tape_and_book_show_nothing() {
1119 let t = organic(200);
1120 let bids: Vec<_> = (0..20)
1121 .map(|i| {
1122 level(
1123 0.999 - i as f64 * 0.0001 * (1.0 + (i % 3) as f64),
1124 100.0 + (i * 53 % 211) as f64,
1125 )
1126 })
1127 .collect();
1128 let p = detect(Ok(&t), Some((&bids, &[])));
1129 assert!(p.findings.is_empty(), "{:?}", p.findings);
1130 assert_eq!(p.bot_like_pct, Some(0.0));
1131 assert!(matches!(
1132 p.check(),
1133 crate::market::types::HealthCheck::Pass(_)
1134 ));
1135 }
1136
1137 #[test]
1138 fn test_one_size_in_a_third_of_the_tape_is_repeated_size() {
1139 let mut t = organic(100);
1140 for tr in t.iter_mut().step_by(3) {
1141 tr.quantity = 1000.0;
1142 }
1143 let (f, idx) = repeated_size(&t).unwrap();
1144 assert_eq!(idx.len(), 34);
1145 assert!(matches!(f, Finding::RepeatedSize { size, count: 34, of: 100 } if size == 1000.0));
1146 assert!(
1147 f.describe().contains("34% of 100 trades are exactly 1000"),
1148 "{}",
1149 f.describe()
1150 );
1151 let mut t = organic(100);
1153 for tr in t.iter_mut().step_by(4) {
1154 tr.quantity = 1000.0;
1155 }
1156 assert!(repeated_size(&t).is_none());
1157 }
1158
1159 #[test]
1160 fn test_a_fixed_cadence_is_a_metronome_and_same_ms_fills_are_one_event() {
1161 let mut t = Vec::new();
1163 for i in 0..60u64 {
1164 t.push(trade(i * 2_000, 1.0, 5.0 + i as f64, TradeSide::Buy));
1165 if i % 5 == 0 {
1166 t.push(trade(i * 2_000, 1.0, 7.0, TradeSide::Buy));
1167 }
1168 }
1169 let Some(Finding::Metronome {
1170 mean_gap_ms,
1171 cv,
1172 dominant_size: None,
1173 ..
1174 }) = metronome(&t)
1175 else {
1176 panic!("no metronome")
1177 };
1178 assert_eq!(mean_gap_ms, 2_000.0);
1179 assert!(cv < 1e-9);
1180 assert!(metronome(&organic(200)).is_none());
1181 }
1182
1183 #[test]
1184 fn test_a_bot_cadence_hidden_in_organic_trades_is_found_by_its_size() {
1185 let mut t = organic(80);
1187 let start = t[0].timestamp_ms;
1188 for i in 0..45u64 {
1189 t.push(trade(start + 7 + i * 3_000, 1.0, 250.0, TradeSide::Sell));
1190 }
1191 let Some(Finding::Metronome {
1192 dominant_size: Some(s),
1193 mean_gap_ms,
1194 ..
1195 }) = metronome(&t)
1196 else {
1197 panic!("no metronome: {:?}", metronome(&t))
1198 };
1199 assert_eq!(s, 250.0);
1200 assert_eq!(mean_gap_ms, 3_000.0);
1201 }
1202
1203 #[test]
1204 fn test_buy_sell_pairs_of_one_size_are_ping_pong() {
1205 let mut t = organic(60);
1206 let start = t.last().unwrap().timestamp_ms + 10_000;
1207 for i in 0..15u64 {
1208 let ms = start + i * 20_000;
1209 let qty = 100.0 + i as f64; t.push(trade(ms, 1.0, qty, TradeSide::Buy));
1211 t.push(trade(ms + 800, 1.0002, qty, TradeSide::Sell));
1212 }
1213 let (f, idx) = ping_pong(&t).unwrap();
1214 assert_eq!(idx.len(), 30);
1215 assert!(matches!(f, Finding::PingPong { pairs: 15, .. }), "{f:?}");
1216 let mut slow = organic(60);
1218 for i in 0..15u64 {
1219 let ms = 9_000_000 + i * 20_000;
1220 slow.push(trade(ms, 1.0, 50.5, TradeSide::Buy));
1221 slow.push(trade(
1222 ms + PING_PONG_WINDOW_MS + 1,
1223 1.0,
1224 50.5,
1225 TradeSide::Sell,
1226 ));
1227 }
1228 assert!(ping_pong(&slow).is_none());
1229 let mut far = organic(60);
1230 for i in 0..15u64 {
1231 let ms = 9_000_000 + i * 20_000;
1232 far.push(trade(ms, 1.0, 50.5, TradeSide::Buy));
1233 far.push(trade(ms + 100, 1.01, 50.5, TradeSide::Sell));
1234 }
1235 assert!(ping_pong(&far).is_none());
1236 }
1237
1238 #[test]
1239 fn test_many_dust_bot_trades_are_little_of_the_volume() {
1240 let mut t: Vec<Trade> = (0..90)
1242 .map(|i| trade(1_000 + i * 977, 100.0, 0.001, TradeSide::Buy))
1243 .collect();
1244 t.extend(
1245 (0..10).map(|i| trade(500_000 + i * 3_331, 100.0, 50.0 + i as f64, TradeSide::Sell)),
1246 );
1247 let p = detect(Ok(&t), None);
1248 assert_eq!(p.bot_like_pct, Some(90.0));
1249 let v = p.bot_like_volume_pct.unwrap();
1250 assert!(v < 0.05, "{v}");
1251 let crate::market::types::HealthCheck::Warn(_) = p.check() else {
1252 panic!()
1253 };
1254 let m = p.evidence().join("\n");
1255 assert!(
1256 m.contains("90% of sampled trades (0.0% of their volume) look bot-made"),
1257 "{m}"
1258 );
1259 }
1260
1261 #[test]
1262 fn test_equal_levels_at_even_steps_are_a_ladder() {
1263 let asks: Vec<_> = (0..12)
1264 .map(|i| level(1.0001 + i as f64 * 0.0001, 5_000.0))
1265 .collect();
1266 let bids: Vec<_> = (0..12)
1268 .map(|i| level(0.9999 - i as f64 * 0.0001, 100.0 + (i * 53 % 211) as f64))
1269 .collect();
1270 let found = ladders(&bids, &asks);
1271 assert_eq!(found.len(), 1);
1272 let Finding::Ladder {
1273 side,
1274 levels,
1275 shape,
1276 size,
1277 step_pct,
1278 ..
1279 } = &found[0]
1280 else {
1281 panic!()
1282 };
1283 assert_eq!(
1284 (*side, *levels, *shape, *size),
1285 (BookSide::Ask, 12, LadderShape::Even, 5_000.0)
1286 );
1287 assert!((step_pct - 0.01).abs() < 0.001, "{step_pct}");
1288 let short_run: Vec<_> = (0..7)
1290 .map(|i| level(1.0 + i as f64 * 0.0001, 5_000.0))
1291 .collect();
1292 assert!(ladders(&[], &short_run).is_empty());
1293 }
1294
1295 #[test]
1296 fn test_findings_warn_with_their_evidence_and_the_bot_like_share() {
1297 let mut t = organic(100);
1298 for tr in t.iter_mut().step_by(2) {
1299 tr.quantity = 1000.0;
1300 }
1301 let p = detect(Ok(&t), None);
1302 let crate::market::types::HealthCheck::Warn(_) = p.check() else {
1303 panic!("{:?}", p.check())
1304 };
1305 let m = p.evidence().join("\n");
1307 assert!(
1308 m.starts_with("Repeated size: 50% of 100 trades are exactly 1000"),
1309 "{m}"
1310 );
1311 assert!(m.contains("50% of sampled trades ("), "{m}");
1312 assert!(m.contains("% of their volume) look bot-made"), "{m}");
1313 assert_eq!(p.bot_like_pct, Some(50.0));
1314 }
1315
1316 #[test]
1317 fn test_no_tape_or_a_short_tape_says_why_and_still_reads_the_book() {
1318 let p = detect(Err("the venue gives no trade feed"), None);
1319 assert_eq!(p.trades_sampled, 0);
1320 let crate::market::types::HealthCheck::NotApplicable(m) = p.check() else {
1321 panic!()
1322 };
1323 assert!(m.contains("the venue gives no trade feed"), "{m}");
1324 let asks: Vec<_> = (0..10)
1326 .map(|i| level(1.0 + i as f64 * 0.001, 42.0))
1327 .collect();
1328 let p = detect(Err("x"), Some((&[], &asks)));
1329 assert!(matches!(
1330 p.check(),
1331 crate::market::types::HealthCheck::Warn(_)
1332 ));
1333 let t = organic(MIN_TRADES - 1);
1335 let p = detect(Ok(&t), None);
1336 assert!(p.tape_note.unwrap().contains("only 29 recent trades"));
1337 assert!(p.bot_like_pct.is_none());
1338 }
1339
1340 #[test]
1341 fn test_each_tape_signal_alone_refuses_a_short_tape() {
1342 let t: Vec<Trade> = (0..MIN_TRADES as u64 - 1)
1346 .map(|i| {
1347 let side = if i % 2 == 0 {
1348 TradeSide::Buy
1349 } else {
1350 TradeSide::Sell
1351 };
1352 trade(i * 2_000, 1.0, 10.0, side)
1353 })
1354 .collect();
1355 assert!(repeated_size(&t).is_none());
1356 assert!(metronome(&t).is_none());
1357 assert!(ping_pong(&t).is_none());
1358 }
1359
1360 #[test]
1361 fn test_a_bid_ladder_is_described_as_a_bid_ladder() {
1362 let bids: Vec<_> = (0..9)
1363 .map(|i| level(0.999 - i as f64 * 0.001, 250.0))
1364 .collect();
1365 let found = ladders(&bids, &[]);
1366 assert_eq!(found.len(), 1);
1367 let d = found[0].describe();
1368 assert!(d.starts_with("Bid ladder: 9 levels × 250 at "), "{d}");
1369 }
1370
1371 #[test]
1372 fn test_a_tape_with_no_volume_gives_the_trade_share_only() {
1373 let t: Vec<Trade> = (0..40)
1376 .map(|i| trade(1_000 + i * 977, 0.0, 3.0, TradeSide::Buy))
1377 .collect();
1378 let p = detect(Ok(&t), None);
1379 assert_eq!(p.bot_like_pct, Some(100.0));
1380 assert_eq!(p.bot_like_volume_pct, None);
1381 let crate::market::types::HealthCheck::Warn(_) = p.check() else {
1382 panic!("{:?}", p.check())
1383 };
1384 let m = p.evidence().join("\n");
1386 assert!(m.ends_with("\n100% of sampled trades look bot-made"), "{m}");
1387 }
1388
1389 fn weex_book() -> (Vec<OrderBookLevel>, Vec<OrderBookLevel>) {
1392 let bids = [
1393 (0.9999, 410.046),
1394 (0.9998, 540.108),
1395 (0.9997, 450.135),
1396 (0.9996, 370.148),
1397 (0.9995, 370.185),
1398 (0.9994, 360.2161),
1399 (0.9993, 300.2101),
1400 (0.9992, 220.1761),
1401 (0.9991, 160.1441),
1402 (0.9990, 120.1201),
1403 (0.0022, 3636.3636),
1404 ];
1405 let asks = [
1406 (1.0001, 427.318),
1407 (1.0002, 1349.73),
1408 (1.0003, 1209.6371),
1409 (1.0004, 1029.5881),
1410 (1.0005, 959.5202),
1411 (1.0006, 919.4483),
1412 (1.0007, 799.4403),
1413 (1.0008, 619.5043),
1414 (1.0009, 399.6403),
1415 (1.0010, 299.7002),
1416 (1.0500, 4.8046),
1417 ];
1418 let side = |l: &[(f64, f64)]| l.iter().map(|&(p, q)| level(p, q)).collect();
1419 (side(&bids), side(&asks))
1420 }
1421
1422 fn weex_tape(cycles: u64) -> Vec<Trade> {
1426 let mut t = Vec::new();
1427 for c in 0..cycles {
1428 let start = 1_000_000 + c * 1_760_000;
1429 let burst = if c % 2 == 0 { 498.11 } else { 68.01 };
1430 let script = [
1431 (0.9999, 500.0, TradeSide::Sell),
1432 (0.9999, 60.05 + (c % 3) as f64 * 10.0, TradeSide::Sell),
1433 (1.0001, burst, TradeSide::Buy),
1434 (1.0001, burst, TradeSide::Buy),
1435 (1.0001, burst, TradeSide::Buy),
1436 (1.0001, burst, TradeSide::Buy),
1437 (1.0001, 300.0 + c as f64 * 7.3, TradeSide::Buy),
1438 (1.0001, 60.0 + c as f64 * 1.9, TradeSide::Buy),
1439 (1.0001, 12.0 + c as f64 * 0.37, TradeSide::Buy),
1440 ];
1441 for (k, (price, qty, side)) in script.into_iter().enumerate() {
1442 let jitter = (c * 7 + k as u64 * 13) % 40;
1444 t.push(trade(start + k as u64 * 45_000 + jitter, price, qty, side));
1445 }
1446 }
1447 t
1448 }
1449
1450 #[test]
1451 fn test_the_weex_usdn_bot_is_caught_on_the_tape_and_the_book() {
1452 let tape = weex_tape(40);
1453 let (bids, asks) = weex_book();
1454 let p = detect(Ok(&tape), Some((&bids, &asks)));
1455 let kinds: Vec<&str> = p
1456 .findings
1457 .iter()
1458 .map(|f| match f {
1459 Finding::RepeatedSize { .. } => "repeated",
1460 Finding::FewSizes { .. } => "few",
1461 Finding::Metronome { .. } => "metronome",
1462 Finding::Cadence { .. } => "cadence",
1463 Finding::Timer { .. } => "timer",
1464 Finding::RoundNotional { .. } => "round",
1465 Finding::PingPong { .. } => "ping-pong",
1466 Finding::Ladder { .. } => "ladder",
1467 Finding::DepthCycle { .. } => "cycle",
1468 })
1469 .collect();
1470 assert_eq!(
1471 kinds,
1472 ["few", "cadence", "ladder", "ladder", "round", "round"],
1473 "{:?}",
1474 p.findings
1475 );
1476 let crate::market::types::HealthCheck::Warn(_) = p.check() else {
1477 panic!("{:?}", p.check())
1478 };
1479 let m = p.evidence().join("\n");
1481 assert!(
1482 m.contains("trades are one of 3 sizes: 498.11, 68.01, 500"),
1483 "{m}"
1484 );
1485 assert!(m.contains("gaps between trades are 45.0s ±2%"), "{m}");
1486 assert!(
1487 m.contains("Bid ladder: 9 levels falling 540.108 → 120.1201 at 0.01% steps"),
1488 "{m}"
1489 );
1490 assert!(
1491 m.contains("Ask ladder: 9 levels falling 1350 → 299.7002 at 0.01% steps"),
1492 "{m}"
1493 );
1494 }
1495
1496 #[test]
1497 fn test_a_few_sizes_in_half_the_tape_is_a_signal_one_rare_size_is_not() {
1498 let mut t = organic(100);
1499 for (i, tr) in t.iter_mut().enumerate() {
1500 match i % 10 {
1501 0 | 1 => tr.quantity = 498.11,
1502 2 | 3 => tr.quantity = 68.01,
1503 4 | 5 => tr.quantity = 500.0,
1504 _ => {}
1505 }
1506 }
1507 assert!(repeated_size(&t).is_none());
1509 let (f, idx) = few_sizes(&t).unwrap();
1510 assert_eq!(idx.len(), 60);
1511 assert!(
1512 matches!(&f, Finding::FewSizes { sizes, count: 60, of: 100 } if sizes == &[498.11, 68.01, 500.0]),
1513 "{f:?}"
1514 );
1515 let mut t = organic(100);
1517 for (i, tr) in t.iter_mut().enumerate() {
1518 if i % 20 < 9 {
1519 tr.quantity = if i % 2 == 0 { 498.11 } else { 68.01 };
1520 }
1521 }
1522 assert!(few_sizes(&t).is_none(), "{:?}", few_sizes(&t));
1523 assert!(few_sizes(&organic(200)).is_none());
1524 }
1525
1526 #[test]
1527 fn test_short_tapes_and_few_gaps_are_not_judged() {
1528 let short_tape = weex_tape(3);
1529 assert!(short_tape.len() < MIN_TRADES);
1530 assert!(few_sizes(&short_tape).is_none());
1531 assert!(cadence(&short_tape).is_none());
1532 let bunched: Vec<Trade> = (0..60u64)
1534 .map(|i| trade((i % 10) * 45_000, 1.0, 5.0 + i as f64, TradeSide::Buy))
1535 .collect();
1536 assert!(cadence(&bunched).is_none());
1537 let same: Vec<Trade> = (0..40)
1539 .map(|i| trade(7, 1.0, 5.0 + i as f64, TradeSide::Buy))
1540 .collect();
1541 assert!(metronome(&same).is_none());
1542 }
1543
1544 #[test]
1545 fn test_steps_read_to_two_significant_digits() {
1546 assert_eq!(step(0.010002), "0.01");
1547 assert_eq!(step(0.123), "0.12");
1548 assert_eq!(step(0.5), "0.5");
1549 assert_eq!(step(12.3), "12");
1550 assert_eq!(step(0.0), "0");
1551 }
1552
1553 #[test]
1554 fn test_bursts_at_one_interval_are_a_cadence_but_not_a_metronome() {
1555 let t = weex_tape(30);
1556 assert!(metronome(&t).is_none());
1557 let Some(Finding::Cadence { gap_ms, share, .. }) = cadence(&t) else {
1558 panic!("{:?}", cadence(&t))
1559 };
1560 assert!((gap_ms - 45_000.0).abs() < 50.0, "{gap_ms}");
1561 assert!(share > 0.85, "{share}");
1562 assert!(cadence(&organic(300)).is_none());
1563 }
1564
1565 #[test]
1566 fn test_random_gaps_in_whole_seconds_are_not_a_cadence() {
1567 let mut ms = 0;
1569 let t: Vec<Trade> = (0..400u64)
1570 .map(|i| {
1571 ms += 1_000 * (1 + (i * 7919 + i * i * 31) % 40);
1572 trade(ms, 1.0, 5.0 + i as f64, TradeSide::Buy)
1573 })
1574 .collect();
1575 assert!(cadence(&t).is_none(), "{:?}", cadence(&t));
1576 let fast: Vec<Trade> = (0..100u64)
1578 .map(|i| {
1579 trade(
1580 i * 1_000 + (i % 2) * 2_000,
1581 1.0,
1582 5.0 + i as f64,
1583 TradeSide::Buy,
1584 )
1585 })
1586 .collect();
1587 assert!(cadence(&fast).is_none(), "{:?}", cadence(&fast));
1588 }
1589
1590 #[test]
1591 fn test_sizes_that_fall_or_rise_level_by_level_are_a_shaped_ladder() {
1592 let (bids, asks) = weex_book();
1593 let found = ladders(&bids, &asks);
1594 assert_eq!(found.len(), 2, "{found:?}");
1595 for f in &found {
1596 assert!(
1597 matches!(
1598 f,
1599 Finding::Ladder {
1600 levels: 9,
1601 shape: LadderShape::Falling,
1602 ..
1603 }
1604 ),
1605 "{f:?}"
1606 );
1607 }
1608 let rising: Vec<_> = (0..10)
1609 .map(|i| level(0.9999 - i as f64 * 0.0001, 100.0 + i as f64 * 25.0))
1610 .collect();
1611 let found = ladders(&rising, &[]);
1612 assert!(
1613 matches!(
1614 found[..],
1615 [Finding::Ladder {
1616 levels: 10,
1617 shape: LadderShape::Rising,
1618 ..
1619 }]
1620 ),
1621 "{found:?}"
1622 );
1623 assert!(
1624 found[0].describe().contains("rising 100 → 325"),
1625 "{}",
1626 found[0].describe()
1627 );
1628 let zigzag: Vec<_> = [
1630 100.0, 200.0, 150.0, 300.0, 250.0, 400.0, 350.0, 500.0, 450.0,
1631 ]
1632 .iter()
1633 .enumerate()
1634 .map(|(i, &q)| level(1.0001 + i as f64 * 0.0001, q))
1635 .collect();
1636 assert!(ladders(&[], &zigzag).is_empty());
1637 }
1638
1639 fn sawtooth(samples: usize, every: usize) -> Vec<(i64, f64)> {
1642 (0..samples)
1643 .map(|i| {
1644 let into = i % every;
1645 let depth = (3_300.0 - into as f64 * 250.0).max(500.0);
1646 (i as i64 * 30_000 + (i as i64 * 7) % 400, depth)
1647 })
1648 .collect()
1649 }
1650
1651 #[test]
1652 fn test_a_book_refilled_on_a_timer_is_a_depth_cycle() {
1653 let c = depth_cycle(&sawtooth(110, 13)).unwrap();
1655 assert_eq!(c.cycles, 7);
1656 assert!((c.period_ms - 390_000.0).abs() < 1_000.0, "{}", c.period_ms);
1657 assert!(c.cv < 0.01, "{}", c.cv);
1658 assert!(depth_cycle(&sawtooth(30, 13)).is_none());
1660 }
1661
1662 #[test]
1663 fn test_a_depth_cycle_is_a_finding_of_its_own_group() {
1664 let tape = weex_tape(40);
1665 let (bids, asks) = weex_book();
1666 let mut p = detect(Ok(&tape), Some((&bids, &asks)));
1667 let before = p.findings.len();
1668 let flat: Vec<(i64, f64)> = (0..120).map(|i| (i * 30_000, 3_300.0)).collect();
1671 let ask = sawtooth(120, 29);
1672 p.set_depth_cycles(&flat, &ask);
1673 p.set_depth_cycles(&flat, &ask);
1675 assert_eq!(p.findings.len(), before + 1, "{:?}", p.findings);
1676 let cycle = p.findings.last().unwrap();
1677 assert_eq!(cycle.label(), "Ask depth cycle");
1678 assert_eq!(
1679 cycle.detail(),
1680 "saw-tooth: drains, then refills every 14m 30s (3 cycles)"
1681 );
1682 let text = p.format_text();
1683 assert!(text.contains("depth over 120 runs"), "{text}");
1684 assert!(text.contains("Ask depth cycle"), "{text}");
1685 let crate::market::types::HealthCheck::Warn(m) = p.check() else {
1686 panic!("{:?}", p.check())
1687 };
1688 assert!(m.ends_with(", ask depth cycle)"), "{m}");
1689 p.set_depth_cycles(&flat, &flat);
1691 assert_eq!(p.findings.len(), before);
1692 }
1693
1694 #[test]
1695 fn test_short_spans_read_as_minutes_and_hours() {
1696 assert_eq!(span(45_000.0), "45s");
1697 assert_eq!(span(870_000.0), "14m 30s");
1698 assert_eq!(span(3_900_000.0), "1h 05m");
1699 }
1700
1701 #[test]
1702 fn test_irregular_refills_and_noise_are_not_a_depth_cycle() {
1703 let mut pts = Vec::new();
1705 let mut t = 0;
1706 for (k, len) in [5, 19, 7, 23, 6, 15, 31].into_iter().enumerate() {
1707 for j in 0..len {
1708 pts.push((t, 3_000.0 * 0.85f64.powi(j) + k as f64));
1709 t += 30_000;
1710 }
1711 }
1712 assert!(depth_cycle(&pts).is_none(), "{:?}", depth_cycle(&pts));
1713 let flip: Vec<(i64, f64)> = (0..60)
1715 .map(|i| (i * 30_000, if i % 2 == 0 { 1_000.0 } else { 2_000.0 }))
1716 .collect();
1717 assert!(depth_cycle(&flip).is_none());
1718 let grow: Vec<(i64, f64)> = (0..100)
1720 .map(|i| {
1721 let into = (i % 10) as f64;
1722 (
1723 i * 30_000,
1724 if into == 0.0 {
1725 5_000.0
1726 } else {
1727 1_000.0 + into * 100.0
1728 },
1729 )
1730 })
1731 .collect();
1732 assert!(depth_cycle(&grow).is_none(), "{:?}", depth_cycle(&grow));
1733 let flat: Vec<(i64, f64)> = (0..100).map(|i| (i * 30_000, 2_000.0)).collect();
1735 assert!(depth_cycle(&flat).is_none());
1736 assert!(depth_cycle(&[]).is_none());
1737 }
1738
1739 fn weex_book_oct26() -> (Vec<OrderBookLevel>, Vec<OrderBookLevel>) {
1743 let bids = [
1744 (0.9999, 389.6396),
1745 (0.9998, 560.112),
1746 (0.9997, 460.138),
1747 (0.9996, 370.148),
1748 (0.9995, 380.19),
1749 (0.9994, 340.2041),
1750 (0.9993, 310.2171),
1751 (0.9992, 230.1841),
1752 (0.9991, 160.1441),
1753 (0.9990, 110.1101),
1754 (0.0022, 3636.3636),
1755 ];
1756 let asks = [
1757 (1.0001, 2379.762),
1758 (1.0002, 1439.712),
1759 (1.0003, 1189.6431),
1760 (1.0004, 1009.5962),
1761 (1.0005, 989.5052),
1762 (1.0006, 919.4483),
1763 (1.0007, 769.4614),
1764 (1.0008, 579.5363),
1765 (1.0009, 399.6403),
1766 (1.0010, 309.6903),
1767 ];
1768 let side = |l: &[(f64, f64)]| l.iter().map(|&(p, q)| level(p, q)).collect();
1769 (side(&bids), side(&asks))
1770 }
1771
1772 fn jittered_tape(n: u64) -> Vec<Trade> {
1776 let mut ms = 1_000_000;
1777 (0..n)
1778 .map(|i| {
1779 ms += 30_000 + (i * 7_919 + i * i * 13) % 30_000;
1780 let side = if (i / 9) % 2 == 0 {
1781 TradeSide::Buy
1782 } else {
1783 TradeSide::Sell
1784 };
1785 trade(
1786 ms,
1787 1.0001,
1788 5.0 + (i * 37 % 431) as f64 + i as f64 / 100.0,
1789 side,
1790 )
1791 })
1792 .collect()
1793 }
1794
1795 #[test]
1796 fn test_the_oct26_weex_bot_is_caught_by_timer_round_notionals_and_ladders() {
1797 let tape = jittered_tape(500);
1798 let (bids, asks) = weex_book_oct26();
1799 let p = detect(Ok(&tape), Some((&bids, &asks)));
1800 let crate::market::types::HealthCheck::Warn(_) = p.check() else {
1801 panic!("{:?}", p.check())
1802 };
1803 let m = p.evidence().join("\n");
1805 assert!(
1806 m.contains("100% of 499 gaps between trades are 30–60s"),
1807 "{m}"
1808 );
1809 assert!(m.contains("Jittered timer: 100% of"), "{m}");
1810 assert!(
1811 m.contains("Bid ladder: 9 levels falling 560.112 → 110.1101"),
1812 "{m}"
1813 );
1814 assert!(
1815 m.contains("Ask ladder: 10 levels falling 2380 → 309.6903"),
1816 "{m}"
1817 );
1818 assert!(
1819 m.contains(
1820 "Bid round notionals: 9 of 10 levels are whole quote amounts (560, 460, 370, …)"
1821 ),
1822 "{m}"
1823 );
1824 assert!(
1825 m.contains(
1826 "Ask round notionals: 10 of 10 levels are whole quote amounts (2380, 1440, 1190, …)"
1827 ),
1828 "{m}"
1829 );
1830 assert!(!m.contains("look bot-made"), "{m}");
1832 assert_eq!(
1833 p.bot_like_volume_pct.map(|v| v.to_string()),
1834 Some("0".to_string())
1835 );
1836 }
1837
1838 #[test]
1839 fn test_a_jittered_timer_is_found_and_real_order_flow_is_not() {
1840 let Some(Finding::Timer {
1841 low_ms,
1842 median_ms,
1843 high_ms,
1844 cv,
1845 ..
1846 }) = timer(&jittered_tape(400))
1847 else {
1848 panic!("{:?}", timer(&jittered_tape(400)))
1849 };
1850 assert!(
1851 low_ms >= 30_000.0 && high_ms <= 60_000.0,
1852 "{low_ms} {high_ms}"
1853 );
1854 assert!((40_000.0..50_000.0).contains(&median_ms), "{median_ms}");
1855 assert!(cv < 0.25, "{cv}");
1856 let mut ms = 0;
1859 let poisson: Vec<Trade> = (0..400u64)
1860 .map(|i| {
1861 ms += [50, 120, 400, 900, 2_500, 6_000, 15_000, 40_000, 110_000]
1862 [(i * 7 % 9) as usize];
1863 trade(ms, 1.0, 5.0 + i as f64, TradeSide::Buy)
1864 })
1865 .collect();
1866 assert!(timer(&poisson).is_none(), "{:?}", timer(&poisson));
1867 assert!(timer(&organic(300)).is_none());
1868 let fast: Vec<Trade> = (0..100u64)
1870 .map(|i| {
1871 trade(
1872 i * 2_000 + (i % 3) * 500,
1873 1.0,
1874 5.0 + i as f64,
1875 TradeSide::Buy,
1876 )
1877 })
1878 .collect();
1879 assert!(timer(&fast).is_none(), "{:?}", timer(&fast));
1880 }
1881
1882 #[test]
1883 fn test_round_notionals_need_most_levels_and_sizes_that_are_not_whole() {
1884 let (bids, asks) = weex_book_oct26();
1885 assert_eq!(round_notionals(&bids, &asks).len(), 2);
1886 let six: Vec<_> = (0..10)
1888 .map(|i| {
1889 let price = 0.9999 - i as f64 * 0.0001;
1890 let qty = if i < 6 {
1891 (300.0 + i as f64 * 10.0) / price
1892 } else {
1893 123.4567 + i as f64
1894 };
1895 level(price, qty)
1896 })
1897 .collect();
1898 assert!(round_notionals(&six, &[]).is_empty());
1899 let people: Vec<_> = (0..10)
1901 .map(|i| level(1.0, 100.0 + i as f64 * 50.0))
1902 .collect();
1903 assert!(round_notionals(&[], &people).is_empty());
1904 }
1905
1906 #[test]
1907 fn test_one_small_bump_does_not_end_a_shaped_ladder_but_a_big_one_does() {
1908 let (bids, _) = weex_book_oct26();
1909 let found = ladders(&bids, &[]);
1910 assert!(
1911 matches!(
1912 found[..],
1913 [Finding::Ladder {
1914 levels: 9,
1915 shape: LadderShape::Falling,
1916 ..
1917 }]
1918 ),
1919 "{found:?}"
1920 );
1921 let sizes = [
1923 900.0, 800.0, 700.0, 600.0, 660.0, 500.0, 400.0, 300.0, 200.0, 100.0,
1924 ];
1925 let bumped: Vec<_> = sizes
1926 .iter()
1927 .enumerate()
1928 .map(|(i, &q)| level(0.9999 - i as f64 * 0.0001, q))
1929 .collect();
1930 assert!(
1931 ladders(&bumped, &[]).is_empty(),
1932 "{:?}",
1933 ladders(&bumped, &[])
1934 );
1935 }
1936
1937 #[test]
1938 fn test_the_check_names_the_signals_and_the_section_lists_them() {
1939 let tape = jittered_tape(500);
1940 let (bids, asks) = weex_book_oct26();
1941 let p = detect(Ok(&tape), Some((&bids, &asks)));
1942 assert_eq!(
1943 p.check().message(),
1944 "Bot patterns: 5 signs of market-making bots (jittered timer, bid ladder, ask ladder, bid round notionals, ask round notionals)"
1945 );
1946 let text = plain(&p.format_text());
1947 let lines: Vec<&str> = text.lines().collect();
1948 assert!(
1949 lines
1950 .iter()
1951 .take(2)
1952 .any(|l| l.contains("Bot Patterns · likely market-making bots")),
1953 "{text}"
1954 );
1955 let at = |needle: &str| {
1957 lines
1958 .iter()
1959 .position(|l| l.contains(needle))
1960 .unwrap_or_else(|| panic!("no {needle} in\n{text}"))
1961 };
1962 assert!(at("Trades") < at("! Jittered timer"), "{text}");
1963 assert!(at("! Jittered timer") < at("Book"), "{text}");
1964 assert!(at("Book") < at("! Bid ladder"), "{text}");
1965 assert!(at("! Bid ladder") < at("! Ask round notionals"), "{text}");
1966 assert!(lines[at("Trades")].contains("500 sampled"), "{text}");
1967 let column = |needle: &str| {
1969 let l = lines[at(needle)];
1970 l.find(needle).unwrap()
1971 + needle.len()
1972 + l[l.find(needle).unwrap() + needle.len()..].len()
1973 - l[l.find(needle).unwrap() + needle.len()..]
1974 .trim_start()
1975 .len()
1976 };
1977 assert_eq!(
1978 column("! Jittered timer"),
1979 column("! Ask round notionals"),
1980 "{text}"
1981 );
1982 assert!(!text.contains("Bot-like share"), "{text}");
1984 }
1985
1986 #[test]
1987 fn test_a_share_line_follows_trade_signals_and_a_book_only_finding_says_why_no_trades() {
1988 let mut t = organic(100);
1989 for tr in t.iter_mut().step_by(2) {
1990 tr.quantity = 1000.0;
1991 }
1992 let text = plain(&detect(Ok(&t), None).format_text());
1993 assert!(text.contains("! Repeated size"), "{text}");
1994 assert!(text.contains("Bot-like share"), "{text}");
1995 assert!(text.contains("50% of sampled trades ("), "{text}");
1996 assert!(
1997 !text.contains("Book "),
1998 "no book group without book signals: {text}"
1999 );
2000 let (bids, asks) = weex_book_oct26();
2001 let book_only = detect(Err("the venue has no trade endpoint"), Some((&bids, &asks)));
2002 let text = plain(&book_only.format_text());
2003 assert!(
2004 text.contains("not checked (the venue has no trade endpoint)"),
2005 "{text}"
2006 );
2007 assert!(text.contains("! Bid ladder"), "{text}");
2008 }
2009
2010 fn bursts_with_pauses(n: u64) -> Vec<Trade> {
2013 let mut ms = 1_000_000;
2014 (0..n)
2015 .map(|i| {
2016 ms += if i % 9 == 8 {
2017 1_080_000 + (i * 7_919) % 420_000
2018 } else {
2019 30_000 + (i * 7_919 + i * i * 13) % 30_000
2020 };
2021 trade(
2022 ms,
2023 1.0001,
2024 5.0 + (i * 37 % 431) as f64 + i as f64 / 100.0,
2025 TradeSide::Buy,
2026 )
2027 })
2028 .collect()
2029 }
2030
2031 #[test]
2032 fn test_a_timer_that_pauses_between_bursts_is_still_a_timer() {
2033 let t = bursts_with_pauses(500);
2034 let Some(
2035 f @ Finding::Timer {
2036 share, pauses, cv, ..
2037 },
2038 ) = timer(&t)
2039 else {
2040 panic!("{:?}", timer(&t))
2041 };
2042 assert!(cv > 1.0, "the pauses blow the spread up: {cv}");
2043 assert!(share > 0.85, "{share}");
2044 assert!(pauses >= 50, "{pauses}");
2045 assert!(f.detail().contains("in bursts with"), "{}", f.detail());
2046 }
2047
2048 #[test]
2049 fn test_random_arrivals_with_a_long_median_gap_are_not_a_timer() {
2050 let mut ms = 0u64;
2053 let t: Vec<Trade> = (0..500u64)
2054 .map(|i| {
2055 let u = ((i as f64 + 0.5) * 0.618_034) % 1.0;
2056 ms += (-(1.0 - u).ln() * 60_000.0) as u64 + 1;
2057 trade(ms, 1.0, 5.0 + i as f64, TradeSide::Buy)
2058 })
2059 .collect();
2060 assert!(timer(&t).is_none(), "{:?}", timer(&t));
2061 }
2062
2063 #[test]
2064 fn test_the_timer_needs_a_tape_and_names_one_pause_in_the_singular() {
2065 assert!(timer(&jittered_tape(10)).is_none());
2067 let bunched: Vec<Trade> = (0..60u64)
2068 .map(|i| trade((i % 5) * 45_000, 1.0, 5.0 + i as f64, TradeSide::Buy))
2069 .collect();
2070 assert!(timer(&bunched).is_none());
2071 let one = Finding::Timer {
2072 low_ms: 30_000.0,
2073 median_ms: 45_000.0,
2074 high_ms: 60_000.0,
2075 cv: 1.2,
2076 gaps: 99,
2077 share: 0.97,
2078 pauses: 1,
2079 };
2080 assert!(
2081 one.detail().ends_with(", in bursts with 1 long pause"),
2082 "{}",
2083 one.detail()
2084 );
2085 let none = Finding::Timer {
2086 low_ms: 30_000.0,
2087 median_ms: 45_000.0,
2088 high_ms: 60_000.0,
2089 cv: 1.2,
2090 gaps: 99,
2091 share: 0.97,
2092 pauses: 0,
2093 };
2094 assert!(none.detail().ends_with("(median 45s)"), "{}", none.detail());
2095 }
2096}