Skip to main content

scope/market/
series.rs

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