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. Four
6//! signals:
7//!
8//! - **Repeated sizes:** one exact trade size makes up a large share of the
9//!   tape.
10//! - **Metronome timing:** trades come at an almost fixed interval.
11//! - **Ping-pong:** a buy and a sell of the same size at nearly the same
12//!   price, seconds apart (the shape of wash trading).
13//! - **Ladder quotes:** consecutive book levels of the same size at even
14//!   price steps.
15//!
16//! The functions are pure: they read trades and levels and fetch nothing.
17
18use super::types::{BookSide, OrderBookLevel, Trade, TradeSide};
19use serde::Serialize;
20
21/// How many recent trades the summary asks the venue for.
22pub const TRADE_SAMPLE: u32 = 500;
23
24/// Fewest trades the tape signals need. Below this they are not judged.
25pub const MIN_TRADES: usize = 30;
26
27/// One exact size in at least this share of the trades is a signal.
28pub const REPEATED_SIZE_SHARE: f64 = 0.30;
29
30/// Fewest gaps between trade events the timing signal needs.
31pub const MIN_GAPS: usize = 20;
32
33/// Gaps whose standard deviation is under this share of their mean are a
34/// metronome.
35pub const METRONOME_MAX_CV: f64 = 0.10;
36
37/// A ping-pong pair is at most this far apart in time.
38pub const PING_PONG_WINDOW_MS: u64 = 5_000;
39
40/// A ping-pong pair's prices differ by at most this, in percent.
41pub const PING_PONG_PRICE_TOLERANCE_PCT: f64 = 0.05;
42
43/// Trades in ping-pong pairs, as a share of all trades, at or above this
44/// are a signal.
45pub const PING_PONG_SHARE: f64 = 0.20;
46
47/// Fewest ping-pong pairs that count as a signal.
48pub const MIN_PING_PONG_PAIRS: usize = 5;
49
50/// Fewest consecutive levels that make a ladder.
51pub const LADDER_MIN_LEVELS: usize = 8;
52
53/// Levels of a ladder differ in size by at most this share.
54pub const LADDER_SIZE_TOLERANCE: f64 = 0.01;
55
56/// Price steps of a ladder differ by at most this share of the first step.
57pub const LADDER_STEP_TOLERANCE: f64 = 0.05;
58
59/// How many levels from the top of each side the ladder signal reads.
60pub const LADDER_DEPTH: usize = 25;
61
62/// Sizes are compared at this many decimal places, so that floats parsed
63/// from the same decimal string compare equal.
64const SIZE_DECIMALS: f64 = 1e8;
65
66/// One signal that was found, with its evidence.
67#[derive(Debug, Clone, PartialEq, Serialize)]
68#[serde(tag = "kind", rename_all = "snake_case")]
69pub enum Finding {
70    /// `count` of `of` trades have the exact size `size` (base units).
71    RepeatedSize { size: f64, count: usize, of: usize },
72    /// Gaps between trade events average `mean_gap_ms`, with a coefficient
73    /// of variation `cv`, over `gaps` gaps. `dominant_size` is set when the
74    /// cadence is that of the repeated-size trades only.
75    Metronome {
76        mean_gap_ms: f64,
77        cv: f64,
78        gaps: usize,
79        dominant_size: Option<f64>,
80    },
81    /// `pairs` buy/sell pairs of the same size, close in time and price,
82    /// cover `share` of the trades.
83    PingPong { pairs: usize, share: f64 },
84    /// `levels` consecutive `side` levels of about `size` (base units), at
85    /// steps of about `step_pct` percent.
86    Ladder {
87        side: BookSide,
88        levels: usize,
89        size: f64,
90        step_pct: f64,
91    },
92}
93
94/// The bot-pattern result of one summary.
95#[derive(Debug, Clone, PartialEq, Serialize)]
96pub struct BotPatterns {
97    /// Trades the tape signals read (0 when there was no tape).
98    pub trades_sampled: usize,
99    /// Why the tape signals did not run, when they did not.
100    pub tape_note: Option<String>,
101    /// The signals found.
102    pub findings: Vec<Finding>,
103    /// Share of the sampled trades, in percent, that a repeated-size or
104    /// ping-pong signal covers. Reported only: it changes no volume figure.
105    pub bot_like_pct: Option<f64>,
106    /// The same trades' share of the sampled volume (price × quantity), in
107    /// percent: many tiny bot trades can be little of the volume.
108    pub bot_like_volume_pct: Option<f64>,
109    /// True when a real exchange book was read for ladders.
110    pub book_checked: bool,
111}
112
113/// A trade size as a comparable key.
114fn size_key(quantity: f64) -> i64 {
115    (quantity * SIZE_DECIMALS).round() as i64
116}
117
118/// The most common exact size and the trades that have it.
119fn dominant_size(trades: &[Trade]) -> Option<(f64, Vec<usize>)> {
120    let mut by_size: std::collections::HashMap<i64, Vec<usize>> = Default::default();
121    for (i, t) in trades.iter().enumerate() {
122        if t.quantity > 0.0 {
123            by_size.entry(size_key(t.quantity)).or_default().push(i);
124        }
125    }
126    by_size
127        .into_values()
128        .max_by(|a, b| a.len().cmp(&b.len()).then(b[0].cmp(&a[0])))
129        .map(|idx| (trades[idx[0]].quantity, idx))
130}
131
132/// Repeated sizes: the dominant size and the trades it covers, when it
133/// reaches [`REPEATED_SIZE_SHARE`].
134pub fn repeated_size(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
135    if trades.len() < MIN_TRADES {
136        return None;
137    }
138    let (size, idx) = dominant_size(trades)?;
139    (idx.len() as f64 / trades.len() as f64 >= REPEATED_SIZE_SHARE).then_some((
140        Finding::RepeatedSize {
141            size,
142            count: idx.len(),
143            of: trades.len(),
144        },
145        idx,
146    ))
147}
148
149/// Gaps between trade events. Trades with the same timestamp are one event:
150/// one taker order that fills several makers prints them together.
151fn event_gaps(mut times: Vec<u64>) -> Vec<f64> {
152    times.sort_unstable();
153    times.dedup();
154    times.windows(2).map(|w| (w[1] - w[0]) as f64).collect()
155}
156
157/// Mean and coefficient of variation of `gaps`.
158fn mean_cv(gaps: &[f64]) -> Option<(f64, f64)> {
159    let n = gaps.len() as f64;
160    let mean = gaps.iter().sum::<f64>() / n;
161    if mean <= 0.0 {
162        return None;
163    }
164    let var = gaps.iter().map(|g| (g - mean).powi(2)).sum::<f64>() / n;
165    Some((mean, var.sqrt() / mean))
166}
167
168/// Metronome timing: the whole tape, else the dominant size's trades, at a
169/// near-fixed interval.
170pub fn metronome(trades: &[Trade]) -> Option<Finding> {
171    if trades.len() < MIN_TRADES {
172        return None;
173    }
174    let judge = |times: Vec<u64>, dominant: Option<f64>| {
175        let gaps = event_gaps(times);
176        if gaps.len() < MIN_GAPS {
177            return None;
178        }
179        let (mean, cv) = mean_cv(&gaps)?;
180        (cv < METRONOME_MAX_CV).then_some(Finding::Metronome {
181            mean_gap_ms: mean,
182            cv,
183            gaps: gaps.len(),
184            dominant_size: dominant,
185        })
186    };
187    judge(trades.iter().map(|t| t.timestamp_ms).collect(), None).or_else(|| {
188        let (size, idx) = dominant_size(trades)?;
189        judge(
190            idx.iter().map(|&i| trades[i].timestamp_ms).collect(),
191            Some(size),
192        )
193    })
194}
195
196/// Ping-pong: buy/sell pairs of the same size within
197/// [`PING_PONG_WINDOW_MS`] and [`PING_PONG_PRICE_TOLERANCE_PCT`]. Each trade
198/// is in one pair at most. Returns the finding and the paired trades.
199pub fn ping_pong(trades: &[Trade]) -> Option<(Finding, Vec<usize>)> {
200    if trades.len() < MIN_TRADES {
201        return None;
202    }
203    let mut order: Vec<usize> = (0..trades.len()).collect();
204    order.sort_by_key(|&i| trades[i].timestamp_ms);
205    let mut paired = vec![false; trades.len()];
206    let mut members = Vec::new();
207    for (pos, &i) in order.iter().enumerate() {
208        if paired[i] {
209            continue;
210        }
211        let a = &trades[i];
212        for &j in &order[pos + 1..] {
213            let b = &trades[j];
214            if b.timestamp_ms - a.timestamp_ms > PING_PONG_WINDOW_MS {
215                break;
216            }
217            let opposite = matches!(
218                (&a.side, &b.side),
219                (TradeSide::Buy, TradeSide::Sell) | (TradeSide::Sell, TradeSide::Buy)
220            );
221            let near = a.price > 0.0
222                && ((b.price - a.price).abs() / a.price * 100.0) <= PING_PONG_PRICE_TOLERANCE_PCT;
223            if !paired[j] && opposite && near && size_key(a.quantity) == size_key(b.quantity) {
224                paired[i] = true;
225                paired[j] = true;
226                members.extend([i, j]);
227                break;
228            }
229        }
230    }
231    let pairs = members.len() / 2;
232    let share = members.len() as f64 / trades.len() as f64;
233    (pairs >= MIN_PING_PONG_PAIRS && share >= PING_PONG_SHARE)
234        .then_some((Finding::PingPong { pairs, share }, members))
235}
236
237/// The longest ladder in `levels` (best first): consecutive levels of about
238/// one size at about one price step.
239fn ladder_side(levels: &[OrderBookLevel], side: BookSide) -> Option<Finding> {
240    let levels: Vec<&OrderBookLevel> = levels
241        .iter()
242        .filter(|l| l.price > 0.0 && l.quantity > 0.0)
243        .take(LADDER_DEPTH)
244        .collect();
245    let mut best: Option<(usize, usize)> = None; // (start, length)
246    let mut start = 0;
247    while start + 1 < levels.len() {
248        let size = levels[start].quantity;
249        let step = (levels[start + 1].price - levels[start].price).abs();
250        let mut end = start + 1;
251        while end < levels.len() {
252            let same_size = (levels[end].quantity - size).abs() <= size * LADDER_SIZE_TOLERANCE;
253            let this_step = (levels[end].price - levels[end - 1].price).abs();
254            let same_step = step > 0.0 && (this_step - step).abs() <= step * LADDER_STEP_TOLERANCE;
255            if !(same_size && same_step) {
256                break;
257            }
258            end += 1;
259        }
260        let len = end - start;
261        if best.is_none_or(|(_, l)| len > l) {
262            best = Some((start, len));
263        }
264        start = end.max(start + 1);
265    }
266    let (s, len) = best?;
267    if len < LADDER_MIN_LEVELS {
268        return None;
269    }
270    let step = (levels[s + 1].price - levels[s].price).abs();
271    Some(Finding::Ladder {
272        side,
273        levels: len,
274        size: levels[s].quantity,
275        step_pct: step / levels[s].price * 100.0,
276    })
277}
278
279/// Ladder quotes on either side of a real exchange book.
280pub fn ladders(bids: &[OrderBookLevel], asks: &[OrderBookLevel]) -> Vec<Finding> {
281    [
282        ladder_side(bids, BookSide::Bid),
283        ladder_side(asks, BookSide::Ask),
284    ]
285    .into_iter()
286    .flatten()
287    .collect()
288}
289
290/// Runs every signal. `trades` is the venue's recent tape, or why there is
291/// none. `book` is the bids and asks of a real exchange book, or `None`
292/// for a synthetic one (a DEX pool's curve is an even ladder by design).
293pub fn detect(
294    trades: Result<&[Trade], &str>,
295    book: Option<(&[OrderBookLevel], &[OrderBookLevel])>,
296) -> BotPatterns {
297    let mut findings = Vec::new();
298    let mut tape_note = None;
299    let mut bot_like_pct = None;
300    let mut bot_like_volume_pct = None;
301    let mut sampled = 0;
302    match trades {
303        Err(why) => tape_note = Some(why.to_string()),
304        Ok(t) if t.len() < MIN_TRADES => {
305            sampled = t.len();
306            tape_note = Some(format!(
307                "only {} recent trades; the tape signals need {MIN_TRADES}",
308                t.len()
309            ));
310        }
311        Ok(t) => {
312            sampled = t.len();
313            let mut covered = vec![false; t.len()];
314            if let Some((f, idx)) = repeated_size(t) {
315                idx.iter().for_each(|&i| covered[i] = true);
316                findings.push(f);
317            }
318            if let Some(f) = metronome(t) {
319                findings.push(f);
320            }
321            if let Some((f, idx)) = ping_pong(t) {
322                idx.iter().for_each(|&i| covered[i] = true);
323                findings.push(f);
324            }
325            let n = covered.iter().filter(|c| **c).count();
326            bot_like_pct = Some(n as f64 / t.len() as f64 * 100.0);
327            let value = |tr: &Trade| tr.quote_quantity.unwrap_or(tr.price * tr.quantity);
328            let total: f64 = t.iter().map(value).sum();
329            let bot: f64 = t
330                .iter()
331                .zip(&covered)
332                .filter(|(_, c)| **c)
333                .map(|(tr, _)| value(tr))
334                .sum();
335            bot_like_volume_pct = (total > 0.0).then(|| bot / total * 100.0);
336        }
337    }
338    if let Some((bids, asks)) = book {
339        findings.extend(ladders(bids, asks));
340    }
341    BotPatterns {
342        trades_sampled: sampled,
343        tape_note,
344        findings,
345        bot_like_pct,
346        bot_like_volume_pct,
347        book_checked: book.is_some(),
348    }
349}
350
351/// A size or step in short form: up to 6 significant digits.
352fn short(v: f64) -> String {
353    let s = format!("{:.6}", v);
354    let s = s.trim_end_matches('0').trim_end_matches('.');
355    if v.abs() >= 1000.0 {
356        format!("{v:.0}")
357    } else {
358        s.to_string()
359    }
360}
361
362impl Finding {
363    /// One clause of evidence for the advisory line.
364    pub fn describe(&self) -> String {
365        match self {
366            Finding::RepeatedSize { size, count, of } => format!(
367                "{:.0}% of {of} trades are exactly {}",
368                *count as f64 / *of as f64 * 100.0,
369                short(*size)
370            ),
371            Finding::Metronome {
372                mean_gap_ms,
373                cv,
374                gaps,
375                dominant_size,
376            } => {
377                let whose = dominant_size
378                    .map(|s| format!("size-{} trades", short(s)))
379                    .unwrap_or_else(|| "trades".to_string());
380                format!(
381                    "{whose} every {:.1}s ±{:.0}% over {gaps} gaps",
382                    mean_gap_ms / 1000.0,
383                    cv * 100.0
384                )
385            }
386            Finding::PingPong { pairs, share } => format!(
387                "{pairs} buy/sell ping-pong pairs ({:.0}% of trades)",
388                share * 100.0
389            ),
390            Finding::Ladder {
391                side,
392                levels,
393                size,
394                step_pct,
395            } => format!(
396                "{} ladder of {levels} levels × {} at {}% steps",
397                match side {
398                    BookSide::Bid => "bid",
399                    BookSide::Ask => "ask",
400                },
401                short(*size),
402                short(*step_pct)
403            ),
404        }
405    }
406}
407
408impl BotPatterns {
409    /// The health check line. A finding warns; nothing found passes when
410    /// the tape was read; with no tape and no ladder it does not apply.
411    pub fn check(&self) -> super::types::HealthCheck {
412        use super::types::HealthCheck;
413        if !self.findings.is_empty() {
414            let evidence: Vec<String> = self.findings.iter().map(Finding::describe).collect();
415            let share = match (
416                self.bot_like_pct.filter(|p| *p > 0.0),
417                self.bot_like_volume_pct,
418            ) {
419                (Some(p), Some(v)) => {
420                    format!("; {p:.0}% of sampled trades ({v:.1}% of their volume) look bot-made")
421                }
422                (Some(p), None) => format!("; {p:.0}% of sampled trades look bot-made"),
423                _ => String::new(),
424            };
425            return HealthCheck::Warn(format!(
426                "Bot patterns (likely market-making bots): {}{share}",
427                evidence.join("; ")
428            ));
429        }
430        let book = if self.book_checked {
431            " or the book"
432        } else {
433            ""
434        };
435        match &self.tape_note {
436            None => HealthCheck::Pass(format!(
437                "No bot patterns in {} recent trades{book}",
438                self.trades_sampled
439            )),
440            Some(why) if self.book_checked => HealthCheck::NotApplicable(format!(
441                "Bot patterns: no ladder in the book; trades not checked ({why})"
442            )),
443            Some(why) => HealthCheck::NotApplicable(format!("Bot patterns: not checked ({why})")),
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn trade(ms: u64, price: f64, qty: f64, side: TradeSide) -> Trade {
453        Trade {
454            price,
455            quantity: qty,
456            quote_quantity: None,
457            timestamp_ms: ms,
458            side,
459            id: None,
460        }
461    }
462
463    /// An organic-looking tape: varied sizes, irregular gaps, sides mixed.
464    fn organic(n: usize) -> Vec<Trade> {
465        let mut ms = 1_000_000;
466        (0..n)
467            .map(|i| {
468                ms += 300 + (i as u64 * 7919) % 4_000;
469                let qty = 10.0 + ((i * 37) % 97) as f64 * 1.37;
470                let side = if (i * 13) % 3 == 0 {
471                    TradeSide::Buy
472                } else {
473                    TradeSide::Sell
474                };
475                trade(ms, 1.0 + ((i * 11) % 7) as f64 * 0.0001, qty, side)
476            })
477            .collect()
478    }
479
480    fn level(price: f64, quantity: f64) -> OrderBookLevel {
481        OrderBookLevel { price, quantity }
482    }
483
484    #[test]
485    fn test_an_organic_tape_and_book_show_nothing() {
486        let t = organic(200);
487        let bids: Vec<_> = (0..20)
488            .map(|i| {
489                level(
490                    0.999 - i as f64 * 0.0001 * (1.0 + (i % 3) as f64),
491                    100.0 + (i * 53 % 211) as f64,
492                )
493            })
494            .collect();
495        let p = detect(Ok(&t), Some((&bids, &[])));
496        assert!(p.findings.is_empty(), "{:?}", p.findings);
497        assert_eq!(p.bot_like_pct, Some(0.0));
498        assert!(matches!(
499            p.check(),
500            crate::market::types::HealthCheck::Pass(_)
501        ));
502    }
503
504    #[test]
505    fn test_one_size_in_a_third_of_the_tape_is_repeated_size() {
506        let mut t = organic(100);
507        for tr in t.iter_mut().step_by(3) {
508            tr.quantity = 1000.0;
509        }
510        let (f, idx) = repeated_size(&t).unwrap();
511        assert_eq!(idx.len(), 34);
512        assert!(matches!(f, Finding::RepeatedSize { size, count: 34, of: 100 } if size == 1000.0));
513        assert!(
514            f.describe().contains("34% of 100 trades are exactly 1000"),
515            "{}",
516            f.describe()
517        );
518        // A quarter is not enough.
519        let mut t = organic(100);
520        for tr in t.iter_mut().step_by(4) {
521            tr.quantity = 1000.0;
522        }
523        assert!(repeated_size(&t).is_none());
524    }
525
526    #[test]
527    fn test_a_fixed_cadence_is_a_metronome_and_same_ms_fills_are_one_event() {
528        // 60 trades every 2s, some printed twice in the same millisecond.
529        let mut t = Vec::new();
530        for i in 0..60u64 {
531            t.push(trade(i * 2_000, 1.0, 5.0 + i as f64, TradeSide::Buy));
532            if i % 5 == 0 {
533                t.push(trade(i * 2_000, 1.0, 7.0, TradeSide::Buy));
534            }
535        }
536        let Some(Finding::Metronome {
537            mean_gap_ms,
538            cv,
539            dominant_size: None,
540            ..
541        }) = metronome(&t)
542        else {
543            panic!("no metronome")
544        };
545        assert_eq!(mean_gap_ms, 2_000.0);
546        assert!(cv < 1e-9);
547        assert!(metronome(&organic(200)).is_none());
548    }
549
550    #[test]
551    fn test_a_bot_cadence_hidden_in_organic_trades_is_found_by_its_size() {
552        // Organic trades plus a bot printing size 250 every 3s.
553        let mut t = organic(80);
554        let start = t[0].timestamp_ms;
555        for i in 0..45u64 {
556            t.push(trade(start + 7 + i * 3_000, 1.0, 250.0, TradeSide::Sell));
557        }
558        let Some(Finding::Metronome {
559            dominant_size: Some(s),
560            mean_gap_ms,
561            ..
562        }) = metronome(&t)
563        else {
564            panic!("no metronome: {:?}", metronome(&t))
565        };
566        assert_eq!(s, 250.0);
567        assert_eq!(mean_gap_ms, 3_000.0);
568    }
569
570    #[test]
571    fn test_buy_sell_pairs_of_one_size_are_ping_pong() {
572        let mut t = organic(60);
573        let start = t.last().unwrap().timestamp_ms + 10_000;
574        for i in 0..15u64 {
575            let ms = start + i * 20_000;
576            let qty = 100.0 + i as f64; // a new size per pair
577            t.push(trade(ms, 1.0, qty, TradeSide::Buy));
578            t.push(trade(ms + 800, 1.0002, qty, TradeSide::Sell));
579        }
580        let (f, idx) = ping_pong(&t).unwrap();
581        assert_eq!(idx.len(), 30);
582        assert!(matches!(f, Finding::PingPong { pairs: 15, .. }), "{f:?}");
583        // Too far apart in time, or in price, is not a pair.
584        let mut slow = organic(60);
585        for i in 0..15u64 {
586            let ms = 9_000_000 + i * 20_000;
587            slow.push(trade(ms, 1.0, 50.5, TradeSide::Buy));
588            slow.push(trade(
589                ms + PING_PONG_WINDOW_MS + 1,
590                1.0,
591                50.5,
592                TradeSide::Sell,
593            ));
594        }
595        assert!(ping_pong(&slow).is_none());
596        let mut far = organic(60);
597        for i in 0..15u64 {
598            let ms = 9_000_000 + i * 20_000;
599            far.push(trade(ms, 1.0, 50.5, TradeSide::Buy));
600            far.push(trade(ms + 100, 1.01, 50.5, TradeSide::Sell));
601        }
602        assert!(ping_pong(&far).is_none());
603    }
604
605    #[test]
606    fn test_many_dust_bot_trades_are_little_of_the_volume() {
607        // Seen live on BTC/USDT: most trades are one tiny size.
608        let mut t: Vec<Trade> = (0..90)
609            .map(|i| trade(1_000 + i * 977, 100.0, 0.001, TradeSide::Buy))
610            .collect();
611        t.extend(
612            (0..10).map(|i| trade(500_000 + i * 3_331, 100.0, 50.0 + i as f64, TradeSide::Sell)),
613        );
614        let p = detect(Ok(&t), None);
615        assert_eq!(p.bot_like_pct, Some(90.0));
616        let v = p.bot_like_volume_pct.unwrap();
617        assert!(v < 0.05, "{v}");
618        let crate::market::types::HealthCheck::Warn(m) = p.check() else {
619            panic!()
620        };
621        assert!(
622            m.contains("90% of sampled trades (0.0% of their volume) look bot-made"),
623            "{m}"
624        );
625    }
626
627    #[test]
628    fn test_equal_levels_at_even_steps_are_a_ladder() {
629        let asks: Vec<_> = (0..12)
630            .map(|i| level(1.0001 + i as f64 * 0.0001, 5_000.0))
631            .collect();
632        let bids: Vec<_> = (0..12)
633            .map(|i| level(0.9999 - i as f64 * 0.0001, 100.0 + i as f64 * 9.0))
634            .collect();
635        let found = ladders(&bids, &asks);
636        assert_eq!(found.len(), 1);
637        let Finding::Ladder {
638            side,
639            levels,
640            size,
641            step_pct,
642        } = &found[0]
643        else {
644            panic!()
645        };
646        assert_eq!((*side, *levels, *size), (BookSide::Ask, 12, 5_000.0));
647        assert!((step_pct - 0.01).abs() < 0.001, "{step_pct}");
648        // Seven levels are not a ladder.
649        let short_run: Vec<_> = (0..7)
650            .map(|i| level(1.0 + i as f64 * 0.0001, 5_000.0))
651            .collect();
652        assert!(ladders(&[], &short_run).is_empty());
653    }
654
655    #[test]
656    fn test_findings_warn_with_their_evidence_and_the_bot_like_share() {
657        let mut t = organic(100);
658        for tr in t.iter_mut().step_by(2) {
659            tr.quantity = 1000.0;
660        }
661        let p = detect(Ok(&t), None);
662        let crate::market::types::HealthCheck::Warn(m) = p.check() else {
663            panic!("{:?}", p.check())
664        };
665        assert!(
666            m.starts_with(
667                "Bot patterns (likely market-making bots): 50% of 100 trades are exactly 1000"
668            ),
669            "{m}"
670        );
671        assert!(m.contains("50% of sampled trades ("), "{m}");
672        assert!(m.contains("% of their volume) look bot-made"), "{m}");
673        assert_eq!(p.bot_like_pct, Some(50.0));
674    }
675
676    #[test]
677    fn test_no_tape_or_a_short_tape_says_why_and_still_reads_the_book() {
678        let p = detect(Err("the venue gives no trade feed"), None);
679        assert_eq!(p.trades_sampled, 0);
680        let crate::market::types::HealthCheck::NotApplicable(m) = p.check() else {
681            panic!()
682        };
683        assert!(m.contains("the venue gives no trade feed"), "{m}");
684        // A ladder still warns without a tape.
685        let asks: Vec<_> = (0..10)
686            .map(|i| level(1.0 + i as f64 * 0.001, 42.0))
687            .collect();
688        let p = detect(Err("x"), Some((&[], &asks)));
689        assert!(matches!(
690            p.check(),
691            crate::market::types::HealthCheck::Warn(_)
692        ));
693        // Fewer than MIN_TRADES: the tape signals do not run.
694        let t = organic(MIN_TRADES - 1);
695        let p = detect(Ok(&t), None);
696        assert!(p.tape_note.unwrap().contains("only 29 recent trades"));
697        assert!(p.bot_like_pct.is_none());
698    }
699
700    #[test]
701    fn test_each_tape_signal_alone_refuses_a_short_tape() {
702        // detect() checks the length first, but the signals are public and
703        // must not judge a tape too short on their own either: 29 trades of
704        // one size, every 2s, in buy/sell pairs, would trip all three.
705        let t: Vec<Trade> = (0..MIN_TRADES as u64 - 1)
706            .map(|i| {
707                let side = if i % 2 == 0 {
708                    TradeSide::Buy
709                } else {
710                    TradeSide::Sell
711                };
712                trade(i * 2_000, 1.0, 10.0, side)
713            })
714            .collect();
715        assert!(repeated_size(&t).is_none());
716        assert!(metronome(&t).is_none());
717        assert!(ping_pong(&t).is_none());
718    }
719
720    #[test]
721    fn test_a_bid_ladder_is_described_as_a_bid_ladder() {
722        let bids: Vec<_> = (0..9)
723            .map(|i| level(0.999 - i as f64 * 0.001, 250.0))
724            .collect();
725        let found = ladders(&bids, &[]);
726        assert_eq!(found.len(), 1);
727        let d = found[0].describe();
728        assert!(d.starts_with("bid ladder of 9 levels × 250 at "), "{d}");
729    }
730
731    #[test]
732    fn test_a_tape_with_no_volume_gives_the_trade_share_only() {
733        // Zero-price prints have no volume to take a share of, so the
734        // advisory gives the trade share alone.
735        let t: Vec<Trade> = (0..40)
736            .map(|i| trade(1_000 + i * 977, 0.0, 3.0, TradeSide::Buy))
737            .collect();
738        let p = detect(Ok(&t), None);
739        assert_eq!(p.bot_like_pct, Some(100.0));
740        assert_eq!(p.bot_like_volume_pct, None);
741        let crate::market::types::HealthCheck::Warn(m) = p.check() else {
742            panic!("{:?}", p.check())
743        };
744        assert!(m.ends_with("; 100% of sampled trades look bot-made"), "{m}");
745    }
746}