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