1use crate::market::health::HealthThresholds;
20use crate::market::health::MarketSummary;
21use crate::market::types::HealthCheck;
22
23macro_rules! metric_ids {
26 ($($(#[$doc:meta])* $name:ident),+ $(,)?) => {
27 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29 pub enum MetricId {
30 $($(#[$doc])* $name),+
31 }
32
33 impl MetricId {
34 pub const ALL: &'static [MetricId] = &[$(MetricId::$name),+];
37 }
38 };
39}
40
41metric_ids! {
42 BestBid,
43 BestAsk,
44 Mid,
45 PegDeviationPct,
47 Spread,
49 SpreadPct,
51 BidDepth,
52 AskDepth,
53 BidTop3,
54 AskTop3,
55 BidTop10,
56 AskTop10,
57 Healthy,
59 PriceUsd,
61 PriceChange24hPct,
63 Volume24h,
65 LiquidityUsd,
67 Holders,
69 NativeBalance,
71 RiskScore,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum MetricGroup {
78 Market,
80 Token,
82 Address,
84}
85
86impl MetricId {
87 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 pub fn name(self) -> &'static str {
100 describe(self, &HealthThresholds::default()).csv_column
101 }
102
103 pub fn from_name(name: &str) -> Option<MetricId> {
105 MetricId::ALL.iter().copied().find(|id| id.name() == name)
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq)]
111pub enum Threshold {
112 Max(f64),
114 Min(f64),
116 Target(f64),
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum Unit {
123 Price,
125 Percent,
126 Quote,
128 Flag,
130 Usd,
132 Count,
134 Native,
136 Score,
138}
139
140#[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#[derive(Debug, Clone, PartialEq)]
153pub struct Sample {
154 pub values: Vec<Option<f64>>,
155 pub healthy: bool,
157 pub failed_checks: Vec<String>,
159}
160
161#[derive(Debug, thiserror::Error, PartialEq, Eq)]
163#[error("the metric table has no entry for {0:?}")]
164pub struct MissingMetric(pub MetricId);
165
166const 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
177pub 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
186pub 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
267pub 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
285pub 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 _ => None,
322 }
323}
324
325const CSV_LEAD: &str = "timestamp,run";
327
328const CSV_NO_VALUE: &str = "-";
330
331pub 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
342pub 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 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 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 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 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; 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 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 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 #[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}