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