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