use super::types::{ExecutionEstimate, HealthCheck, OrderBook, OrderBookLevel};
use serde::{Deserialize, Serialize};
pub const DEFAULT_PEG_TARGET: f64 = 1.0;
pub const DEFAULT_PEG_RANGE: f64 = 0.001;
pub const DEFAULT_MIN_LEVELS: usize = 10;
pub const DEFAULT_MIN_DEPTH: f64 = 3000.0;
pub const DEFAULT_MIN_BID_ASK_RATIO: f64 = 0.2;
pub const DEFAULT_MAX_BID_ASK_RATIO: f64 = 5.0;
pub const DEFAULT_MAX_SPREAD_PCT: f64 = 3.0;
pub const DEFAULT_MIN_TOP3_DEPTH: f64 = 300.0;
pub const DEFAULT_MIN_TOP10_DEPTH: f64 = 2000.0;
pub const TOP3_LEVELS: usize = 3;
pub const TOP10_LEVELS: usize = 10;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct HealthThresholds {
pub peg_target: f64,
pub peg_range: f64,
pub min_levels: usize,
pub min_depth: f64,
pub min_bid_ask_ratio: f64,
pub max_bid_ask_ratio: f64,
pub max_spread_pct: f64,
pub min_top3_depth: f64,
pub min_top10_depth: f64,
}
impl Default for HealthThresholds {
fn default() -> Self {
Self {
peg_target: DEFAULT_PEG_TARGET,
peg_range: DEFAULT_PEG_RANGE,
min_levels: DEFAULT_MIN_LEVELS,
min_depth: DEFAULT_MIN_DEPTH,
min_bid_ask_ratio: DEFAULT_MIN_BID_ASK_RATIO,
max_bid_ask_ratio: DEFAULT_MAX_BID_ASK_RATIO,
max_spread_pct: DEFAULT_MAX_SPREAD_PCT,
min_top3_depth: DEFAULT_MIN_TOP3_DEPTH,
min_top10_depth: DEFAULT_MIN_TOP10_DEPTH,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct HealthOverrides {
#[serde(alias = "peg")]
pub peg_target: Option<f64>,
pub peg_range: Option<f64>,
pub min_levels: Option<usize>,
pub min_depth: Option<f64>,
pub min_bid_ask_ratio: Option<f64>,
pub max_bid_ask_ratio: Option<f64>,
pub max_spread_pct: Option<f64>,
pub min_top3_depth: Option<f64>,
pub min_top10_depth: Option<f64>,
}
impl HealthThresholds {
pub fn with_overrides(&self, o: &HealthOverrides) -> Self {
Self {
peg_target: o.peg_target.unwrap_or(self.peg_target),
peg_range: o.peg_range.unwrap_or(self.peg_range),
min_levels: o.min_levels.unwrap_or(self.min_levels),
min_depth: o.min_depth.unwrap_or(self.min_depth),
min_bid_ask_ratio: o.min_bid_ask_ratio.unwrap_or(self.min_bid_ask_ratio),
max_bid_ask_ratio: o.max_bid_ask_ratio.unwrap_or(self.max_bid_ask_ratio),
max_spread_pct: o.max_spread_pct.unwrap_or(self.max_spread_pct),
min_top3_depth: o.min_top3_depth.unwrap_or(self.min_top3_depth),
min_top10_depth: o.min_top10_depth.unwrap_or(self.min_top10_depth),
}
}
}
fn is_valid_level(level: &OrderBookLevel) -> bool {
level.price > 0.0 && level.quantity > 0.0
}
fn top_n_depth(levels: &[OrderBookLevel], n: usize) -> f64 {
levels
.iter()
.filter(|l| is_valid_level(l))
.take(n)
.map(OrderBookLevel::value)
.sum()
}
#[derive(Debug, Clone)]
pub struct MarketSummary {
pub pair: String,
pub peg_target: f64,
pub best_bid: Option<f64>,
pub best_ask: Option<f64>,
pub mid_price: Option<f64>,
pub spread: Option<f64>,
pub volume_24h: Option<f64>,
pub execution_10k_buy: Option<ExecutionEstimate>,
pub execution_10k_sell: Option<ExecutionEstimate>,
pub asks: Vec<OrderBookLevel>,
pub bids: Vec<OrderBookLevel>,
pub ask_outliers: usize,
pub bid_outliers: usize,
pub ask_depth: f64,
pub bid_depth: f64,
pub checks: Vec<HealthCheck>,
pub healthy: bool,
}
impl MarketSummary {
pub fn from_order_book(
book: &OrderBook,
thresholds: &HealthThresholds,
volume_24h: Option<f64>,
) -> Self {
let peg_target = thresholds.peg_target;
let price_lo = peg_target - thresholds.peg_range * 5.0;
let price_hi = peg_target + thresholds.peg_range * 5.0;
let asks: Vec<OrderBookLevel> = book
.asks
.iter()
.filter(|l| l.price <= price_hi)
.cloned()
.collect();
let bids: Vec<OrderBookLevel> = book
.bids
.iter()
.filter(|l| l.price >= price_lo)
.cloned()
.collect();
let ask_outliers = book.asks.len().saturating_sub(asks.len());
let bid_outliers = book.bids.len().saturating_sub(bids.len());
let ask_depth: f64 = asks.iter().map(OrderBookLevel::value).sum();
let bid_depth: f64 = bids.iter().map(OrderBookLevel::value).sum();
let mut checks = Vec::new();
let below_peg: Vec<_> = asks.iter().filter(|a| a.price < peg_target).collect();
if below_peg.is_empty() {
checks.push(HealthCheck::Pass("No sells below peg".to_string()));
} else {
let usdt = below_peg.iter().map(|a| a.value()).sum::<f64>();
checks.push(HealthCheck::Fail(format!(
"{} sell(s) below peg ({:.0} USDT)",
below_peg.len(),
usdt
)));
}
let ratio = if ask_depth > 0.0 {
bid_depth / ask_depth
} else {
0.0
};
if ratio < thresholds.min_bid_ask_ratio || ratio > thresholds.max_bid_ask_ratio {
checks.push(HealthCheck::Fail(format!("Bid/Ask ratio: {:.2}x", ratio)));
} else {
checks.push(HealthCheck::Pass(format!("Bid/Ask ratio: {:.2}x", ratio)));
}
let valid_bids = book.bids.iter().filter(|l| is_valid_level(l)).count();
let valid_asks = book.asks.iter().filter(|l| is_valid_level(l)).count();
let best_valid_bid = book.bids.iter().find(|l| is_valid_level(l));
let best_valid_ask = book.asks.iter().find(|l| is_valid_level(l));
match (best_valid_bid, best_valid_ask) {
(Some(b), Some(a)) => {
let mid = (a.price + b.price) / 2.0;
let spread_pct = (a.price - b.price) / mid * 100.0;
let msg = format!(
"Spread: {:.3}% (max {:.1}%)",
spread_pct, thresholds.max_spread_pct
);
if spread_pct > thresholds.max_spread_pct {
checks.push(HealthCheck::Fail(msg));
} else {
checks.push(HealthCheck::Pass(msg));
}
}
_ => checks.push(HealthCheck::Fail("Spread: no valid bid or ask".to_string())),
}
if valid_bids < thresholds.min_levels {
checks.push(HealthCheck::Fail(format!(
"Bid levels: {} < {} minimum",
valid_bids, thresholds.min_levels
)));
}
if bid_depth < thresholds.min_depth {
checks.push(HealthCheck::Fail(format!(
"Bid depth: {:.0} USDT < {:.0} USDT minimum",
bid_depth, thresholds.min_depth
)));
}
if valid_asks < thresholds.min_levels {
checks.push(HealthCheck::Fail(format!(
"Ask levels: {} < {} minimum",
valid_asks, thresholds.min_levels
)));
}
if ask_depth < thresholds.min_depth {
checks.push(HealthCheck::Fail(format!(
"Ask depth: {:.0} USDT < {:.0} USDT minimum",
ask_depth, thresholds.min_depth
)));
}
for (side, levels) in [("Bid", &book.bids), ("Ask", &book.asks)] {
for (n, min) in [
(TOP3_LEVELS, thresholds.min_top3_depth),
(TOP10_LEVELS, thresholds.min_top10_depth),
] {
let depth = top_n_depth(levels, n);
if depth < min {
checks.push(HealthCheck::Fail(format!(
"{side} top-{n} depth: {depth:.0} USDT < {min:.0} USDT minimum"
)));
}
}
}
let healthy = checks.iter().all(|c| matches!(c, HealthCheck::Pass(_)));
let execution_10k_buy = book.estimate_buy_execution(10_000.0);
let execution_10k_sell = book.estimate_sell_execution(10_000.0);
Self {
pair: book.pair.clone(),
peg_target,
best_bid: book.best_bid(),
best_ask: book.best_ask(),
mid_price: book.mid_price(),
spread: book.spread(),
volume_24h,
execution_10k_buy,
execution_10k_sell,
asks,
bids,
ask_outliers,
bid_outliers,
ask_depth,
bid_depth,
checks,
healthy,
}
}
pub fn format_text(&self, venue_or_chain: Option<&str>) -> String {
use crate::display::terminal as t;
let mut out = String::new();
let title = match venue_or_chain {
Some(c) => format!("{} ({})", self.pair, c),
None => self.pair.clone(),
};
out.push_str(&t::section_header(&title));
out.push('\n');
out.push_str(&t::subsection_header("Metrics"));
out.push('\n');
if let Some(c) = venue_or_chain {
out.push_str(&t::kv_row("Venue", c));
out.push('\n');
}
out.push_str(&t::kv_row("Peg Target", &format!("{:.4}", self.peg_target)));
out.push('\n');
if let Some(bb) = self.best_bid {
let pct = (bb - self.peg_target) / self.peg_target * 100.0;
out.push_str(&t::kv_row(
"Best Bid",
&format!(
"{} ({:+.3}%)",
t::format_price_peg(bb, self.peg_target),
pct
),
));
out.push('\n');
}
if let Some(ba) = self.best_ask {
let pct = (ba - self.peg_target) / self.peg_target * 100.0;
out.push_str(&t::kv_row(
"Best Ask",
&format!(
"{} ({:+.3}%)",
t::format_price_peg(ba, self.peg_target),
pct
),
));
out.push('\n');
}
if let Some(mid) = self.mid_price {
let pct = (mid - self.peg_target) / self.peg_target * 100.0;
out.push_str(&t::kv_row(
"Mid Price",
&format!(
"{} ({:+.3}%)",
t::format_price_peg(mid, self.peg_target),
pct
),
));
out.push('\n');
}
if let (Some(spread), Some(mid)) = (self.spread, self.mid_price)
&& mid > 0.0
{
out.push_str(&t::kv_row(
"Spread",
&format!("{:.4} ({:.3}%)", spread, spread / mid * 100.0),
));
out.push('\n');
}
if let Some(v) = self.volume_24h {
out.push_str(&t::kv_row("Volume (24h)", &format!("{:.0} USDT", v)));
out.push('\n');
}
if let Some(e) = &self.execution_10k_buy {
let msg = if e.fillable {
format!("~{:.2} bps slippage", e.slippage_bps)
} else {
"insufficient liquidity".to_string()
};
out.push_str(&t::kv_row("Exec 10K buy", &msg));
out.push('\n');
}
if let Some(e) = &self.execution_10k_sell {
let msg = if e.fillable {
format!("~{:.2} bps slippage", e.slippage_bps)
} else {
"insufficient liquidity".to_string()
};
out.push_str(&t::kv_row("Exec 10K sell", &msg));
out.push('\n');
}
let base_symbol = self.pair.split('/').next().unwrap_or("BASE");
let ask_label = if self.ask_outliers > 0 {
format!(
"Ask Side — {} levels, {:.0} USDT (+{} outliers excl.)",
self.asks.len(),
self.ask_depth,
self.ask_outliers
)
} else {
format!(
"Ask Side — {} levels, {:.0} USDT",
self.asks.len(),
self.ask_depth
)
};
out.push_str(&t::subsection_header(&ask_label));
out.push('\n');
for level in self.asks.iter().take(8) {
out.push_str(&t::orderbook_level(
level.price,
level.quantity,
base_symbol,
level.value(),
self.peg_target,
));
out.push('\n');
}
if self.asks.len() > 8 {
out.push_str(&t::blank_row());
out.push_str(&format!(" ... +{} more levels\n", self.asks.len() - 8));
}
let bid_label = if self.bid_outliers > 0 {
format!(
"Bid Side — {} levels, {:.0} USDT (+{} outliers excl.)",
self.bids.len(),
self.bid_depth,
self.bid_outliers
)
} else {
format!(
"Bid Side — {} levels, {:.0} USDT",
self.bids.len(),
self.bid_depth
)
};
out.push_str(&t::subsection_header(&bid_label));
out.push('\n');
for level in self.bids.iter().take(8) {
out.push_str(&t::orderbook_level(
level.price,
level.quantity,
base_symbol,
level.value(),
self.peg_target,
));
out.push('\n');
}
if self.bids.len() > 8 {
out.push_str(&t::blank_row());
out.push_str(&format!(" ... +{} more levels\n", self.bids.len() - 8));
}
out.push_str(&t::subsection_header("Health Checks"));
out.push('\n');
for check in &self.checks {
match check {
HealthCheck::Pass(msg) => {
out.push_str(&t::check_pass(msg));
out.push('\n');
}
HealthCheck::Fail(msg) => {
out.push_str(&t::check_fail(msg));
out.push('\n');
}
}
}
out.push_str(&t::blank_row());
out.push('\n');
out.push_str(&t::status_line(self.healthy));
out.push('\n');
out.push_str(&t::section_footer());
out.push('\n');
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn side(start: f64, step: f64, qtys: &[f64]) -> Vec<OrderBookLevel> {
qtys.iter()
.enumerate()
.map(|(i, &quantity)| OrderBookLevel {
price: start + step * i as f64,
quantity,
})
.collect()
}
fn healthy_book() -> OrderBook {
OrderBook {
pair: "USDC/USDT".to_string(),
bids: side(0.9999, -0.0001, &[400.0; 10]),
asks: side(1.0001, 0.0001, &[400.0; 10]),
}
}
fn summarize(book: &OrderBook) -> MarketSummary {
MarketSummary::from_order_book(book, &HealthThresholds::default(), None)
}
fn failed(summary: &MarketSummary) -> Vec<String> {
summary
.checks
.iter()
.filter_map(|c| match c {
HealthCheck::Fail(m) => Some(m.clone()),
HealthCheck::Pass(_) => None,
})
.collect()
}
#[test]
fn test_market_summary_from_order_book() {
let summary = summarize(&healthy_book());
assert!(summary.healthy, "{:?}", failed(&summary));
assert_eq!(summary.bids.len(), 10);
assert_eq!(summary.asks.len(), 10);
assert!(summary.bid_depth > 3000.0);
assert!(summary.ask_depth > 3000.0);
}
#[test]
fn test_peg_comes_from_thresholds() {
let thresholds = HealthThresholds {
peg_target: 1.01,
peg_range: 0.01,
..HealthThresholds::default()
};
let summary = MarketSummary::from_order_book(&healthy_book(), &thresholds, None);
assert_eq!(summary.peg_target, 1.01);
assert!(
failed(&summary).iter().any(|m| m.contains("below peg")),
"{:?}",
failed(&summary)
);
}
#[test]
fn test_default_criteria_values() {
let t = HealthThresholds::default();
assert_eq!(t.min_levels, 10);
assert_eq!(t.max_spread_pct, 3.0);
assert_eq!(t.min_top3_depth, 300.0);
assert_eq!(t.min_top10_depth, 2000.0);
assert_eq!(t.min_depth, 3000.0);
}
#[test]
fn test_nine_levels_is_unhealthy() {
let mut book = healthy_book();
book.bids = side(0.9999, -0.0001, &[1000.0; 9]);
let summary = summarize(&book);
assert!(!summary.healthy);
assert_eq!(failed(&summary), vec!["Bid levels: 9 < 10 minimum"]);
}
#[test]
fn test_zero_quantity_level_is_not_valid() {
let mut book = healthy_book();
book.asks[9].quantity = 0.0;
book.asks.push(OrderBookLevel {
price: 1.0011,
quantity: 0.0,
});
let summary = summarize(&book);
assert!(
failed(&summary).contains(&"Ask levels: 9 < 10 minimum".to_string()),
"{:?}",
failed(&summary)
);
}
#[test]
fn test_spread_over_three_percent_is_unhealthy() {
let mut book = healthy_book();
book.asks = side(1.04, 0.0001, &[400.0; 10]);
let summary = summarize(&book);
assert!(!summary.healthy);
assert!(
failed(&summary).iter().any(|m| m.starts_with("Spread:")),
"{:?}",
failed(&summary)
);
}
#[test]
fn test_spread_under_three_percent_passes_spread_check() {
let mut book = healthy_book();
book.asks = side(1.02, 0.0001, &[400.0; 10]);
let summary = summarize(&book);
assert!(
!failed(&summary).iter().any(|m| m.starts_with("Spread:")),
"{:?}",
failed(&summary)
);
}
#[test]
fn test_thin_top3_is_unhealthy_even_with_deep_book() {
let mut book = healthy_book();
let mut qtys = vec![50.0; 3];
qtys.extend([500.0; 7]);
book.bids = side(0.9999, -0.0001, &qtys);
let summary = summarize(&book);
assert_eq!(
failed(&summary),
vec!["Bid top-3 depth: 150 USDT < 300 USDT minimum"]
);
}
#[test]
fn test_top10_counts_only_first_ten_levels() {
let mut book = healthy_book();
let mut qtys = vec![150.0; 10];
qtys.push(2000.0);
book.asks = side(1.0001, 0.0001, &qtys);
let summary = summarize(&book);
assert!(summary.ask_depth >= 3000.0);
let fails = failed(&summary);
assert_eq!(fails.len(), 1, "{fails:?}");
assert!(fails[0].starts_with("Ask top-10 depth:"), "{fails:?}");
}
#[test]
fn test_format_text_with_chain() {
let book = OrderBook {
pair: "USDC/USDT".to_string(),
bids: vec![OrderBookLevel {
price: 1.0,
quantity: 100.0,
}],
asks: vec![OrderBookLevel {
price: 1.0,
quantity: 100.0,
}],
};
let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
let out = summary.format_text(Some("biconomy"));
assert!(out.contains("biconomy"));
assert!(out.contains("Venue"));
}
#[test]
fn test_format_text_without_chain() {
let book = OrderBook {
pair: "X/Y".to_string(),
bids: vec![OrderBookLevel {
price: 1.0,
quantity: 10.0,
}],
asks: vec![],
};
let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
let out = summary.format_text(None);
assert!(out.contains("X/Y"));
assert!(!out.contains("Venue"));
}
#[test]
fn test_health_check_sells_below_peg() {
let book = OrderBook {
pair: "USDC/USDT".to_string(),
bids: vec![OrderBookLevel {
price: 0.9995,
quantity: 1000.0,
}],
asks: vec![
OrderBookLevel {
price: 0.9990,
quantity: 100.0,
}, OrderBookLevel {
price: 1.0001,
quantity: 500.0,
},
],
};
let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
assert!(!summary.healthy);
let has_fail = summary
.checks
.iter()
.any(|c| matches!(c, HealthCheck::Fail(m) if m.contains("sell")));
assert!(has_fail);
}
#[test]
fn test_format_text_with_volume_and_spread() {
let book = OrderBook {
pair: "USDC/USDT".to_string(),
bids: vec![
OrderBookLevel {
price: 0.9999,
quantity: 1000.0,
},
OrderBookLevel {
price: 0.9998,
quantity: 1000.0,
},
],
asks: vec![
OrderBookLevel {
price: 1.0001,
quantity: 1000.0,
},
OrderBookLevel {
price: 1.0002,
quantity: 1000.0,
},
],
};
let summary =
MarketSummary::from_order_book(&book, &HealthThresholds::default(), Some(50_000.0));
let out = summary.format_text(Some("binance"));
assert!(out.contains("Volume (24h)"));
assert!(out.contains("50000"));
assert!(out.contains("Spread"));
}
#[test]
fn test_format_text_with_outliers_and_many_levels() {
let mut asks: Vec<OrderBookLevel> = (0..12)
.map(|i| OrderBookLevel {
price: 1.0 + 0.0001 * (i + 1) as f64, quantity: 500.0,
})
.collect();
asks.push(OrderBookLevel {
price: 1.01, quantity: 100.0,
});
let book = OrderBook {
pair: "USDC/USDT".to_string(),
bids: vec![
OrderBookLevel {
price: 0.9999,
quantity: 600.0,
},
OrderBookLevel {
price: 0.9998,
quantity: 600.0,
},
OrderBookLevel {
price: 0.9997,
quantity: 600.0,
},
OrderBookLevel {
price: 0.9996,
quantity: 600.0,
},
OrderBookLevel {
price: 0.9995,
quantity: 600.0,
},
OrderBookLevel {
price: 0.9994,
quantity: 600.0,
},
],
asks,
};
let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
let out = summary.format_text(None);
assert!(
out.contains("outliers excl.") || summary.ask_outliers > 0 || summary.bid_outliers > 0
);
assert!(out.contains("... +")); }
#[test]
fn test_format_text_execution_fillable() {
let book = OrderBook {
pair: "USDC/USDT".to_string(),
bids: vec![OrderBookLevel {
price: 0.9999,
quantity: 20_000.0,
}],
asks: vec![OrderBookLevel {
price: 1.0001,
quantity: 20_000.0,
}],
};
let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
let out = summary.format_text(Some("cex"));
assert!(out.contains("Exec 10K buy"));
assert!(out.contains("Exec 10K sell"));
assert!(out.contains("slippage") || out.contains("insufficient"));
}
#[test]
fn test_format_text_bid_outliers_and_many_bid_levels() {
let mut bids: Vec<OrderBookLevel> = (0..12)
.map(|i| OrderBookLevel {
price: 1.0 - 0.0001 * (i + 1) as f64,
quantity: 500.0,
})
.collect();
bids.push(OrderBookLevel {
price: 0.98, quantity: 100.0,
});
let book = OrderBook {
pair: "USDC/USDT".to_string(),
bids,
asks: vec![
OrderBookLevel {
price: 1.0001,
quantity: 600.0,
},
OrderBookLevel {
price: 1.0002,
quantity: 600.0,
},
],
};
let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
let out = summary.format_text(None);
assert!(summary.bid_outliers > 0);
assert!(out.contains("outliers excl."));
assert!(out.contains("... +"));
}
}