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