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 LargestTrade,
62 VolumeChangePct,
65 PriceUsd,
67 PriceChange24hPct,
69 Volume24h,
71 LiquidityUsd,
73 Holders,
75 NativeBalance,
77 RiskScore,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum MetricGroup {
84 Market,
86 Token,
88 Address,
90}
91
92impl MetricId {
93 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 pub fn since_last_tick(self) -> bool {
108 matches!(self, MetricId::LargestTrade | MetricId::VolumeChangePct)
109 }
110
111 pub fn name(self) -> &'static str {
113 describe(self, &HealthThresholds::default()).csv_column
114 }
115
116 pub fn from_name(name: &str) -> Option<MetricId> {
118 MetricId::ALL.iter().copied().find(|id| id.name() == name)
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq)]
124pub enum Threshold {
125 Max(f64),
127 Min(f64),
129 Target(f64),
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum Unit {
136 Price,
138 Percent,
139 Quote,
141 Flag,
143 Usd,
145 Count,
147 Native,
149 Score,
151}
152
153#[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#[derive(Debug, Clone, PartialEq)]
166pub struct Sample {
167 pub values: Vec<Option<f64>>,
168 pub healthy: bool,
170 pub failed_checks: Vec<String>,
172}
173
174#[derive(Debug, thiserror::Error, PartialEq, Eq)]
176#[error("the metric table has no entry for {0:?}")]
177pub struct MissingMetric(pub MetricId);
178
179const 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
190pub 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
200pub 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
283pub 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
301pub 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 _ => None,
338 }
339}
340
341const CSV_LEAD: &str = "timestamp,run";
343
344const CSV_NO_VALUE: &str = "-";
346
347pub 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
358pub 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 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 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 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 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; 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 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 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 #[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}