Skip to main content

scope/
metrics.rs

1//! The metric catalog: one table of key values, one reading per run.
2//!
3//! Market metrics are the key values of a market summary. Token and address
4//! metrics are read by watchlists (`scope::watch`). Each [`MetricId`] has one
5//! [`MetricGroup`]; [`describe`] gives its label, unit and threshold.
6//!
7//! [`summary_metrics`] defines the market list once. Labels, units, thresholds and
8//! CSV column names all come from it. [`sample`] reads one [`MarketSummary`]
9//! into values in the same order as the table, and [`csv_columns`] gives the
10//! CSV order of the same table.
11//!
12//! Every market value is a field the summary computed once for its health checks
13//! (spread, top-N depth, health). This module only reads them, so a charted
14//! value and the check that judged it cannot differ.
15//!
16//! The table has no `timestamp` or `run` entry: the CSV writer adds those
17//! two columns in front. This module has no presentation types.
18
19use crate::market::health::HealthThresholds;
20use crate::market::health::MarketSummary;
21use crate::market::types::HealthCheck;
22
23/// Declares [`MetricId`] and [`MetricId::ALL`] from one list of variants, so
24/// the list of all ids cannot fall out of step with the enum.
25macro_rules! metric_ids {
26    ($($(#[$doc:meta])* $name:ident),+ $(,)?) => {
27        /// Identifies one metric of the catalog.
28        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29        pub enum MetricId {
30            $($(#[$doc])* $name),+
31        }
32
33        impl MetricId {
34            /// Every id, in table order. `describe` matches over each
35            /// variant, so a new variant also needs a table entry.
36            pub const ALL: &'static [MetricId] = &[$(MetricId::$name),+];
37        }
38    };
39}
40
41metric_ids! {
42    BestBid,
43    BestAsk,
44    Mid,
45    /// `(mid - peg_target) / peg_target * 100`.
46    PegDeviationPct,
47    /// Absolute spread of the best valid bid and ask.
48    Spread,
49    /// The same spread in percent of its mid.
50    SpreadPct,
51    BidDepth,
52    AskDepth,
53    BidTop3,
54    AskTop3,
55    BidTop10,
56    AskTop10,
57    /// `1.0` when [`Sample::healthy`] is true, else `0.0`.
58    Healthy,
59    /// Token price in USD.
60    PriceUsd,
61    /// Token price change over 24h, in percent.
62    PriceChange24hPct,
63    /// Token trading volume over 24h, in USD.
64    Volume24h,
65    /// Token liquidity in USD.
66    LiquidityUsd,
67    /// Number of token holders.
68    Holders,
69    /// An address's balance of the chain's native coin.
70    NativeBalance,
71    /// An address's overall risk score, 0 to 10.
72    RiskScore,
73}
74
75/// Where a metric's value comes from.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum MetricGroup {
78    /// A market summary (order book health).
79    Market,
80    /// Token analytics (DEX data).
81    Token,
82    /// An address analysis or its risk assessment.
83    Address,
84}
85
86impl MetricId {
87    /// The group of this metric.
88    pub fn group(self) -> MetricGroup {
89        use MetricId::*;
90        match self {
91            BestBid | BestAsk | Mid | PegDeviationPct | Spread | SpreadPct | BidDepth
92            | AskDepth | BidTop3 | AskTop3 | BidTop10 | AskTop10 | Healthy => MetricGroup::Market,
93            PriceUsd | PriceChange24hPct | Volume24h | LiquidityUsd | Holders => MetricGroup::Token,
94            NativeBalance | RiskScore => MetricGroup::Address,
95        }
96    }
97
98    /// The metric's name in rules and CSV files (`spread_pct`, `holders`).
99    pub fn name(self) -> &'static str {
100        describe(self, &HealthThresholds::default()).csv_column
101    }
102
103    /// The metric with this name, if any.
104    pub fn from_name(name: &str) -> Option<MetricId> {
105        MetricId::ALL.iter().copied().find(|id| id.name() == name)
106    }
107}
108
109/// The limit a metric is judged against.
110#[derive(Debug, Clone, Copy, PartialEq)]
111pub enum Threshold {
112    /// The value must not exceed this.
113    Max(f64),
114    /// The value must not fall below this.
115    Min(f64),
116    /// The value should be at this.
117    Target(f64),
118}
119
120/// What a metric's number measures.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum Unit {
123    /// A price in the quote asset.
124    Price,
125    Percent,
126    /// An amount in the quote asset (for example USDT).
127    Quote,
128    /// `1.0` or `0.0`.
129    Flag,
130    /// An amount in US dollars.
131    Usd,
132    /// A whole number of things (holders).
133    Count,
134    /// An amount of a chain's native coin.
135    Native,
136    /// A score (risk, 0 to 10).
137    Score,
138}
139
140/// One entry of the metric table.
141#[derive(Debug, Clone, PartialEq)]
142pub struct Metric {
143    pub id: MetricId,
144    pub label: &'static str,
145    pub unit: Unit,
146    pub threshold: Option<Threshold>,
147    pub csv_column: &'static str,
148}
149
150/// One run's reading. `values[i]` belongs to the metric at index `i` of the
151/// table passed to [`sample`]. A missing value is `None`, never zero.
152#[derive(Debug, Clone, PartialEq)]
153pub struct Sample {
154    pub values: Vec<Option<f64>>,
155    /// The one source of health: the `Healthy` metric value is derived from it.
156    pub healthy: bool,
157    /// Messages of the checks that failed, in check order.
158    pub failed_checks: Vec<String>,
159}
160
161/// A CSV column the caller needs is missing from the table it passed.
162#[derive(Debug, thiserror::Error, PartialEq, Eq)]
163#[error("the metric table has no entry for {0:?}")]
164pub struct MissingMetric(pub MetricId);
165
166/// CSV columns that exist today, in their fixed order. Other columns follow.
167const LEGACY_CSV_ORDER: [MetricId; 7] = [
168    MetricId::BestBid,
169    MetricId::BestAsk,
170    MetricId::Mid,
171    MetricId::Spread,
172    MetricId::BidDepth,
173    MetricId::AskDepth,
174    MetricId::Healthy,
175];
176
177/// The market metric table, built from the thresholds in force.
178pub fn summary_metrics(thresholds: &HealthThresholds) -> Vec<Metric> {
179    MetricId::ALL
180        .iter()
181        .filter(|id| id.group() == MetricGroup::Market)
182        .map(|id| describe(*id, thresholds))
183        .collect()
184}
185
186/// The catalog entry of `id`. Market thresholds come from `t`; token and
187/// address metrics have no default threshold.
188pub fn describe(id: MetricId, t: &HealthThresholds) -> Metric {
189    use MetricId::*;
190    use Threshold::{Max, Min, Target};
191    let (label, unit, threshold, csv_column) = match id {
192        BestBid => ("Best bid", Unit::Price, None, "best_bid"),
193        BestAsk => ("Best ask", Unit::Price, None, "best_ask"),
194        Mid => (
195            "Mid price",
196            Unit::Price,
197            Some(Target(t.peg_target)),
198            "mid_price",
199        ),
200        PegDeviationPct => ("Peg deviation %", Unit::Percent, None, "peg_deviation_pct"),
201        Spread => ("Spread", Unit::Price, None, "spread"),
202        SpreadPct => (
203            "Spread %",
204            Unit::Percent,
205            Some(Max(t.max_spread_pct)),
206            "spread_pct",
207        ),
208        BidDepth => (
209            "Bid depth",
210            Unit::Quote,
211            Some(Min(t.min_depth)),
212            "bid_depth",
213        ),
214        AskDepth => (
215            "Ask depth",
216            Unit::Quote,
217            Some(Min(t.min_depth)),
218            "ask_depth",
219        ),
220        BidTop3 => (
221            "Bid top-3 depth",
222            Unit::Quote,
223            Some(Min(t.min_top3_depth)),
224            "bid_top3_depth",
225        ),
226        AskTop3 => (
227            "Ask top-3 depth",
228            Unit::Quote,
229            Some(Min(t.min_top3_depth)),
230            "ask_top3_depth",
231        ),
232        BidTop10 => (
233            "Bid top-10 depth",
234            Unit::Quote,
235            Some(Min(t.min_top10_depth)),
236            "bid_top10_depth",
237        ),
238        AskTop10 => (
239            "Ask top-10 depth",
240            Unit::Quote,
241            Some(Min(t.min_top10_depth)),
242            "ask_top10_depth",
243        ),
244        Healthy => ("Health", Unit::Flag, None, "healthy"),
245        PriceUsd => ("Price", Unit::Usd, None, "price_usd"),
246        PriceChange24hPct => (
247            "Price change 24h %",
248            Unit::Percent,
249            None,
250            "price_change_24h_pct",
251        ),
252        Volume24h => ("Volume 24h", Unit::Usd, None, "volume_24h"),
253        LiquidityUsd => ("Liquidity", Unit::Usd, None, "liquidity_usd"),
254        Holders => ("Holders", Unit::Count, None, "holders"),
255        NativeBalance => ("Native balance", Unit::Native, None, "native_balance"),
256        RiskScore => ("Risk score", Unit::Score, None, "risk_score"),
257    };
258    Metric {
259        id,
260        label,
261        unit,
262        threshold,
263        csv_column,
264    }
265}
266
267/// The table in CSV column order: today's columns first, then the rest in
268/// table order. The writer adds `timestamp` and `run` before these.
269///
270/// Fails when the table lacks one of today's columns, so a writer never
271/// emits a header that silently drops it.
272pub fn csv_columns(metrics: &[Metric]) -> Result<Vec<&Metric>, MissingMetric> {
273    let mut columns = Vec::with_capacity(metrics.len());
274    for id in LEGACY_CSV_ORDER {
275        let metric = metrics
276            .iter()
277            .find(|m| m.id == id)
278            .ok_or(MissingMetric(id))?;
279        columns.push(metric);
280    }
281    columns.extend(metrics.iter().filter(|m| !LEGACY_CSV_ORDER.contains(&m.id)));
282    Ok(columns)
283}
284
285/// Reads one summary into values, in the order of `metrics`.
286pub fn sample(summary: &MarketSummary, metrics: &[Metric]) -> Sample {
287    let healthy = summary.healthy;
288    Sample {
289        values: metrics
290            .iter()
291            .map(|m| value_of(m.id, summary, healthy))
292            .collect(),
293        healthy,
294        failed_checks: summary
295            .checks
296            .iter()
297            .filter_map(|c| match c {
298                HealthCheck::Fail(message) => Some(message.clone()),
299                _ => None,
300            })
301            .collect(),
302    }
303}
304
305fn value_of(id: MetricId, s: &MarketSummary, healthy: bool) -> Option<f64> {
306    match id {
307        MetricId::BestBid => s.best_bid,
308        MetricId::BestAsk => s.best_ask,
309        MetricId::Mid => s.mid_price,
310        MetricId::PegDeviationPct => s.mid_price.and_then(|mid| s.peg_deviation_pct(mid)),
311        MetricId::Spread => s.spread,
312        MetricId::SpreadPct => s.spread_pct,
313        MetricId::BidDepth => Some(s.bid_depth),
314        MetricId::AskDepth => Some(s.ask_depth),
315        MetricId::BidTop3 => Some(s.top_depth.bid_top3),
316        MetricId::AskTop3 => Some(s.top_depth.ask_top3),
317        MetricId::BidTop10 => Some(s.top_depth.bid_top10),
318        MetricId::AskTop10 => Some(s.top_depth.ask_top10),
319        MetricId::Healthy => Some(f64::from(u8::from(healthy))),
320        // A market summary has no token or address values.
321        _ => None,
322    }
323}
324
325/// The CSV columns every series file starts with, before the metrics.
326const CSV_LEAD: &str = "timestamp,run";
327
328/// What a CSV cell holds when the metric has no value.
329const CSV_NO_VALUE: &str = "-";
330
331/// The CSV header line for `columns` (see [`csv_columns`]), with its newline.
332pub fn csv_header(columns: &[&Metric]) -> String {
333    let mut line = CSV_LEAD.to_string();
334    for m in columns {
335        line.push(',');
336        line.push_str(m.csv_column);
337    }
338    line.push('\n');
339    line
340}
341
342/// One CSV row, with its newline. `sample` reads in the order of `metrics`
343/// (the table [`sample`] used); `columns` sets the cell order. A flag is
344/// `true` or `false`; a missing value is `-`.
345pub fn csv_row(
346    columns: &[&Metric],
347    metrics: &[Metric],
348    timestamp: &str,
349    run: u64,
350    sample: &Sample,
351) -> String {
352    let mut line = format!("{timestamp},{run}");
353    for column in columns {
354        let value = metrics
355            .iter()
356            .position(|m| m.id == column.id)
357            .and_then(|i| sample.values.get(i).copied().flatten());
358        line.push(',');
359        match (column.unit, value) {
360            (Unit::Flag, Some(v)) => line.push_str(if v != 0.0 { "true" } else { "false" }),
361            (_, Some(v)) => line.push_str(&v.to_string()),
362            (_, None) => line.push_str(CSV_NO_VALUE),
363        }
364    }
365    line.push('\n');
366    line
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::market::health::{HealthThresholds, MarketSummary};
373    use crate::market::types::{BookSource, OrderBook, OrderBookLevel};
374
375    fn side(start: f64, step: f64, qtys: &[f64]) -> Vec<OrderBookLevel> {
376        qtys.iter()
377            .enumerate()
378            .map(|(i, &quantity)| OrderBookLevel {
379                price: start + step * i as f64,
380                quantity,
381            })
382            .collect()
383    }
384
385    fn book(bids: Vec<OrderBookLevel>, asks: Vec<OrderBookLevel>) -> OrderBook {
386        OrderBook {
387            pair: "USDC/USDT".to_string(),
388            source: BookSource::Exchange,
389            bids,
390            asks,
391        }
392    }
393
394    fn healthy_book() -> OrderBook {
395        book(
396            side(0.9999, -0.0001, &[400.0; 10]),
397            side(1.0001, 0.0001, &[400.0; 10]),
398        )
399    }
400
401    fn summarize(book: &OrderBook) -> MarketSummary {
402        MarketSummary::from_order_book(book, &HealthThresholds::default(), None)
403    }
404
405    fn healthy_summary() -> MarketSummary {
406        summarize(&healthy_book())
407    }
408
409    fn thin_summary() -> MarketSummary {
410        summarize(&book(
411            side(0.9999, -0.0001, &[1.0; 2]),
412            side(1.0001, 0.0001, &[1.0; 2]),
413        ))
414    }
415
416    /// Sum of the first `n` levels, the same way the health module sums them.
417    fn top_sum(levels: &[OrderBookLevel], n: usize) -> f64 {
418        levels.iter().take(n).map(OrderBookLevel::value).sum()
419    }
420
421    fn metric(metrics: &[Metric], id: MetricId) -> &Metric {
422        metrics.iter().find(|m| m.id == id).expect("metric present")
423    }
424
425    fn value(sample: &Sample, metrics: &[Metric], id: MetricId) -> Option<f64> {
426        let at = metrics.iter().position(|m| m.id == id).unwrap();
427        sample.values[at]
428    }
429
430    #[test]
431    fn summary_metrics_are_the_market_group_only() {
432        let m = summary_metrics(&HealthThresholds::default());
433        assert!(m.iter().all(|m| m.id.group() == MetricGroup::Market));
434        assert_eq!(m.len(), 13, "the market table did not change");
435    }
436
437    #[test]
438    fn every_catalog_entry_has_a_unique_name_that_maps_back() {
439        let mut names = std::collections::HashSet::new();
440        for &id in MetricId::ALL {
441            let m = describe(id, &HealthThresholds::default());
442            assert!(!m.label.is_empty(), "{id:?} has no label");
443            assert!(names.insert(id.name()), "{} appears twice", id.name());
444            assert_eq!(MetricId::from_name(id.name()), Some(id));
445        }
446        assert_eq!(MetricId::from_name("no_such_metric"), None);
447    }
448
449    #[test]
450    fn token_and_address_metrics_have_their_groups_and_no_default_threshold() {
451        use MetricId::*;
452        for id in [
453            PriceUsd,
454            PriceChange24hPct,
455            Volume24h,
456            LiquidityUsd,
457            Holders,
458        ] {
459            assert_eq!(id.group(), MetricGroup::Token, "{id:?}");
460        }
461        for id in [NativeBalance, RiskScore] {
462            assert_eq!(id.group(), MetricGroup::Address, "{id:?}");
463        }
464        for id in [PriceUsd, Holders, NativeBalance, RiskScore] {
465            assert_eq!(describe(id, &HealthThresholds::default()).threshold, None);
466        }
467        assert_eq!(Holders.name(), "holders");
468        assert_eq!(
469            describe(Holders, &HealthThresholds::default()).unit,
470            Unit::Count
471        );
472    }
473
474    #[test]
475    fn a_summary_gives_no_value_for_a_token_metric() {
476        let t = HealthThresholds::default();
477        let metrics = vec![describe(MetricId::PriceUsd, &t)];
478        assert_eq!(sample(&healthy_summary(), &metrics).values, vec![None]);
479    }
480
481    #[test]
482    fn every_table_entry_has_a_label_and_a_unique_csv_column() {
483        let metrics = summary_metrics(&HealthThresholds::default());
484        assert!(!metrics.is_empty());
485        let mut columns = std::collections::HashSet::new();
486        for m in &metrics {
487            assert!(!m.label.is_empty(), "{:?} has no label", m.id);
488            assert!(!m.csv_column.is_empty(), "{:?} has no csv column", m.id);
489            assert!(
490                columns.insert(m.csv_column),
491                "{} appears twice",
492                m.csv_column
493            );
494        }
495    }
496
497    #[test]
498    fn thresholds_come_from_health_thresholds() {
499        let t = HealthThresholds {
500            peg_target: 0.5,
501            max_spread_pct: 1.5,
502            min_depth: 700.0,
503            min_top3_depth: 11.0,
504            min_top10_depth: 22.0,
505            ..HealthThresholds::default()
506        };
507        let m = summary_metrics(&t);
508        assert_eq!(
509            metric(&m, MetricId::Mid).threshold,
510            Some(Threshold::Target(0.5))
511        );
512        assert_eq!(
513            metric(&m, MetricId::SpreadPct).threshold,
514            Some(Threshold::Max(1.5))
515        );
516        for id in [MetricId::BidDepth, MetricId::AskDepth] {
517            assert_eq!(metric(&m, id).threshold, Some(Threshold::Min(700.0)));
518        }
519        for id in [MetricId::BidTop3, MetricId::AskTop3] {
520            assert_eq!(metric(&m, id).threshold, Some(Threshold::Min(11.0)));
521        }
522        for id in [MetricId::BidTop10, MetricId::AskTop10] {
523            assert_eq!(metric(&m, id).threshold, Some(Threshold::Min(22.0)));
524        }
525        assert_eq!(metric(&m, MetricId::Healthy).threshold, None);
526    }
527
528    #[test]
529    fn sample_reads_the_summary_values() {
530        let s = healthy_summary();
531        let metrics = summary_metrics(&HealthThresholds::default());
532        let one = sample(&s, &metrics);
533        assert_eq!(one.values.len(), metrics.len());
534        assert!(one.healthy);
535        assert!(one.failed_checks.is_empty());
536        let mid = value(&one, &metrics, MetricId::Mid).unwrap();
537        assert!((mid - 1.0).abs() < 1e-12);
538        // Peg deviation: (mid - peg) / peg * 100.
539        let mut shifted = s.clone();
540        shifted.mid_price = Some(1.002);
541        let dev = value(
542            &sample(&shifted, &metrics),
543            &metrics,
544            MetricId::PegDeviationPct,
545        );
546        assert!((dev.unwrap() - 0.2).abs() < 1e-9);
547        // Spread and spread % are the summary's fields: 0.0002 on a mid of 1.0.
548        let spread_pct = value(&one, &metrics, MetricId::SpreadPct).unwrap();
549        assert!((spread_pct - 0.02).abs() < 1e-9, "{spread_pct}");
550        let spread = value(&one, &metrics, MetricId::Spread).unwrap();
551        assert!((spread - 0.0002).abs() < 1e-12, "{spread}");
552        // Top-N depth: the exact sums of the first 3 and 10 levels.
553        let b = healthy_book();
554        assert_eq!(
555            value(&one, &metrics, MetricId::BidTop3),
556            Some(top_sum(&s.bids, 3))
557        );
558        assert_eq!(
559            value(&one, &metrics, MetricId::AskTop10),
560            Some(top_sum(&b.asks, 10))
561        );
562        assert_eq!(value(&one, &metrics, MetricId::Healthy), Some(1.0));
563        assert_eq!(
564            value(
565                &sample(&thin_summary(), &metrics),
566                &metrics,
567                MetricId::Healthy
568            ),
569            Some(0.0)
570        );
571    }
572
573    #[test]
574    fn missing_values_stay_none() {
575        let empty = MarketSummary::from_order_book(
576            &book(vec![], vec![]),
577            &HealthThresholds::default(),
578            None,
579        );
580        let metrics = summary_metrics(&HealthThresholds::default());
581        let one = sample(&empty, &metrics);
582        for id in [
583            MetricId::Mid,
584            MetricId::PegDeviationPct,
585            MetricId::SpreadPct,
586            MetricId::BestBid,
587            MetricId::BestAsk,
588            MetricId::Spread,
589        ] {
590            assert_eq!(value(&one, &metrics, id), None, "{id:?}");
591        }
592        assert_eq!(value(&one, &metrics, MetricId::Healthy), Some(0.0));
593    }
594
595    #[test]
596    fn failed_checks_list_the_failing_messages() {
597        let thin = MarketSummary::from_order_book(
598            &book(
599                side(0.9999, -0.0001, &[1.0; 2]),
600                side(1.0001, 0.0001, &[1.0; 2]),
601            ),
602            &HealthThresholds::default(),
603            None,
604        );
605        let one = sample(&thin, &summary_metrics(&HealthThresholds::default()));
606        assert!(!one.healthy);
607        assert!(
608            one.failed_checks
609                .iter()
610                .any(|m| m.starts_with("Bid levels"))
611        );
612        assert!(one.failed_checks.iter().any(|m| m.starts_with("Bid depth")));
613        assert!(!one.failed_checks.iter().any(|m| m.starts_with("Spread")));
614    }
615
616    #[test]
617    fn csv_columns_start_with_todays_header_and_append_the_rest() {
618        let metrics = summary_metrics(&HealthThresholds::default());
619        let columns: Vec<&str> = csv_columns(&metrics)
620            .unwrap()
621            .iter()
622            .map(|m| m.csv_column)
623            .collect();
624        assert_eq!(
625            columns[..7],
626            [
627                "best_bid",
628                "best_ask",
629                "mid_price",
630                "spread",
631                "bid_depth",
632                "ask_depth",
633                "healthy"
634            ]
635        );
636        assert_eq!(columns.len(), metrics.len());
637        let mut unique = columns.clone();
638        unique.sort_unstable();
639        unique.dedup();
640        assert_eq!(unique.len(), columns.len(), "csv column names are unique");
641    }
642
643    #[test]
644    fn csv_columns_fail_when_a_legacy_column_is_missing() {
645        let mut metrics = summary_metrics(&HealthThresholds::default());
646        metrics.retain(|m| m.id != MetricId::Spread);
647        assert_eq!(csv_columns(&metrics), Err(MissingMetric(MetricId::Spread)));
648    }
649
650    #[test]
651    fn synthetic_amm_book_has_no_spread_like_its_check() {
652        let mut b = healthy_book();
653        b.source = BookSource::SyntheticAmm;
654        let s = summarize(&b);
655        assert!(matches!(
656            s.checks.iter().find(|c| c.message().starts_with("Spread")),
657            Some(HealthCheck::NotApplicable(_))
658        ));
659        let metrics = summary_metrics(&HealthThresholds::default());
660        let one = sample(&s, &metrics);
661        assert_eq!(value(&one, &metrics, MetricId::Spread), None);
662        assert_eq!(value(&one, &metrics, MetricId::SpreadPct), None);
663        assert!(value(&one, &metrics, MetricId::Mid).is_some());
664    }
665
666    #[test]
667    fn spread_uses_the_best_valid_level_like_its_check() {
668        let mut bids = side(1.0, -0.0001, &[400.0; 10]);
669        bids[0].quantity = 0.0; // invalid top level: the best valid bid is 0.9999
670        let s = summarize(&book(bids, side(1.0001, 0.0001, &[400.0; 10])));
671        let metrics = summary_metrics(&HealthThresholds::default());
672        let one = sample(&s, &metrics);
673        let spread = value(&one, &metrics, MetricId::Spread).unwrap();
674        assert!((spread - 0.0002).abs() < 1e-12, "{spread}");
675        let pct = value(&one, &metrics, MetricId::SpreadPct).unwrap();
676        assert!(
677            s.checks
678                .iter()
679                .any(|c| c.message() == format!("Spread: {pct:.3}% (max 3.0%)"))
680        );
681    }
682
683    #[test]
684    fn top_depth_equals_what_the_check_judged_on_a_book_with_outliers() {
685        // 4 levels near the peg, then 6 outliers far below it.
686        let mut bids = side(0.9999, -0.0001, &[100.0; 4]);
687        bids.extend(side(0.9, -0.01, &[100.0; 6]));
688        let b = book(bids, side(1.0001, 0.0001, &[400.0; 10]));
689        let s = summarize(&b);
690        let metrics = summary_metrics(&HealthThresholds::default());
691        let one = sample(&s, &metrics);
692        let top10 = value(&one, &metrics, MetricId::BidTop10).unwrap();
693        // Eligible levels only: the outliers are not in top-N depth.
694        assert_eq!(top10, top_sum(&s.bids, 10));
695        assert_ne!(top10, top_sum(&b.bids, 10));
696        assert_eq!(top10, s.bid_depth);
697        let judged = format!("Bid top-10 depth: {top10:.0} USDT");
698        assert!(s.checks.iter().any(|c| c.message().starts_with(&judged)));
699        assert_eq!(
700            value(&one, &metrics, MetricId::BidTop3),
701            Some(top_sum(&s.bids, 3))
702        );
703    }
704
705    #[test]
706    fn zero_peg_target_gives_no_deviation() {
707        let metrics = summary_metrics(&HealthThresholds::default());
708        let mut zero_peg = healthy_summary();
709        zero_peg.peg_target = 0.0;
710        let one = sample(&zero_peg, &metrics);
711        assert_eq!(value(&one, &metrics, MetricId::PegDeviationPct), None);
712        assert!(value(&one, &metrics, MetricId::Mid).is_some());
713    }
714
715    #[test]
716    fn a_zero_priced_level_is_not_a_best_price() {
717        let mut bids = side(0.9999, -0.0001, &[400.0; 3]);
718        bids.insert(
719            0,
720            OrderBookLevel {
721                price: 0.0,
722                quantity: 50.0,
723            },
724        );
725        let s = summarize(&book(bids, side(1.0001, 0.0001, &[400.0; 3])));
726        let metrics = summary_metrics(&HealthThresholds::default());
727        let one = sample(&s, &metrics);
728        assert_eq!(value(&one, &metrics, MetricId::BestBid), Some(0.9999));
729        let pct = value(&one, &metrics, MetricId::SpreadPct).unwrap();
730        assert!(pct.is_finite() && pct > 0.0, "{pct}");
731    }
732
733    #[test]
734    fn summary_prices_are_the_order_books_with_a_zero_quantity_top_level() {
735        let mut bids = side(1.0, -0.0001, &[400.0; 10]);
736        bids[0].quantity = 0.0;
737        let mut asks = side(1.0001, 0.0001, &[400.0; 10]);
738        asks[0].quantity = 0.0;
739        let b = book(bids, asks);
740        let s = summarize(&b);
741        assert_eq!(s.best_bid, b.best_bid());
742        assert_eq!(s.best_ask, b.best_ask());
743        assert_eq!(s.mid_price, b.mid_price());
744        assert_eq!(s.spread, b.spread());
745        assert_eq!(s.spread_pct, b.spread_pct());
746        let (bid, ask) = (s.best_bid.unwrap(), s.best_ask.unwrap());
747        assert!((s.spread.unwrap() - (ask - bid)).abs() < 1e-12);
748        let mid = s.mid_price.unwrap();
749        assert!((s.spread_pct.unwrap() - s.spread.unwrap() / mid * 100.0).abs() < 1e-12);
750    }
751
752    // The CSV writer must keep today's header and cells byte for byte, and
753    // only append the new columns.
754    #[test]
755    fn csv_keeps_the_old_columns_first_and_their_cell_format() {
756        let metrics = summary_metrics(&HealthThresholds::default());
757        let columns = csv_columns(&metrics).unwrap();
758        let header = csv_header(&columns);
759        assert!(
760            header.starts_with(
761                "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy,"
762            ),
763            "{header}"
764        );
765        assert!(header.ends_with("ask_top10_depth\n"), "{header}");
766        let mut values = vec![None; metrics.len()];
767        let at = |id| metrics.iter().position(|m| m.id == id).unwrap();
768        values[at(MetricId::BestBid)] = Some(0.9999);
769        values[at(MetricId::BidDepth)] = Some(1500.0);
770        values[at(MetricId::Healthy)] = Some(0.0);
771        let s = Sample {
772            values,
773            healthy: false,
774            failed_checks: vec![],
775        };
776        let row = csv_row(&columns, &metrics, "2026-10-13T00:00:00Z", 7, &s);
777        assert!(
778            row.starts_with("2026-10-13T00:00:00Z,7,0.9999,-,-,-,1500,-,false,"),
779            "{row}"
780        );
781        assert_eq!(row.matches(',').count(), header.matches(',').count());
782    }
783}