1use super::health::HealthThresholds;
16use super::health::MarketSummary;
17use super::types::HealthCheck;
18
19macro_rules! metric_ids {
22 ($($(#[$doc:meta])* $name:ident),+ $(,)?) => {
23 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
25 pub enum MetricId {
26 $($(#[$doc])* $name),+
27 }
28
29 impl MetricId {
30 pub const ALL: &'static [MetricId] = &[$(MetricId::$name),+];
33 }
34 };
35}
36
37metric_ids! {
38 BestBid,
39 BestAsk,
40 Mid,
41 PegDeviationPct,
43 Spread,
45 SpreadPct,
47 BidDepth,
48 AskDepth,
49 BidTop3,
50 AskTop3,
51 BidTop10,
52 AskTop10,
53 Healthy,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq)]
59pub enum Threshold {
60 Max(f64),
62 Min(f64),
64 Target(f64),
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Unit {
71 Price,
73 Percent,
74 Quote,
76 Flag,
78}
79
80#[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#[derive(Debug, Clone, PartialEq)]
93pub struct Sample {
94 pub values: Vec<Option<f64>>,
95 pub healthy: bool,
97 pub failed_checks: Vec<String>,
99}
100
101#[derive(Debug, thiserror::Error, PartialEq, Eq)]
103#[error("the metric table has no entry for {0:?}")]
104pub struct MissingMetric(pub MetricId);
105
106const 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
117pub 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
192pub 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
210pub 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
248const CSV_LEAD: &str = "timestamp,run";
250
251const CSV_NO_VALUE: &str = "-";
253
254pub 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
265pub 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 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 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 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 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; 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 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 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 #[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}