Skip to main content

scope/market/
patterns.rs

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