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