1use crate::error::{Result, ScopeError};
7use async_trait::async_trait;
8use reqwest::Client;
9use serde::Deserialize;
10use std::time::Duration;
11
12#[derive(Debug, Clone, PartialEq)]
18pub struct OrderBookLevel {
19 pub price: f64,
21 pub quantity: f64,
23}
24
25impl OrderBookLevel {
26 #[inline]
28 pub fn value(&self) -> f64 {
29 self.price * self.quantity
30 }
31}
32
33#[derive(Debug, Clone)]
35pub struct OrderBook {
36 pub pair: String,
38 pub bids: Vec<OrderBookLevel>,
40 pub asks: Vec<OrderBookLevel>,
42}
43
44impl OrderBook {
45 pub fn best_bid(&self) -> Option<f64> {
47 self.bids.first().map(|l| l.price)
48 }
49
50 pub fn best_ask(&self) -> Option<f64> {
52 self.asks.first().map(|l| l.price)
53 }
54
55 pub fn mid_price(&self) -> Option<f64> {
57 match (self.best_bid(), self.best_ask()) {
58 (Some(bid), Some(ask)) => Some((bid + ask) / 2.0),
59 _ => None,
60 }
61 }
62
63 pub fn spread(&self) -> Option<f64> {
65 match (self.best_bid(), self.best_ask()) {
66 (Some(bid), Some(ask)) => Some(ask - bid),
67 _ => None,
68 }
69 }
70
71 pub fn bid_depth(&self) -> f64 {
73 self.bids.iter().map(OrderBookLevel::value).sum()
74 }
75
76 pub fn ask_depth(&self) -> f64 {
78 self.asks.iter().map(OrderBookLevel::value).sum()
79 }
80
81 pub fn estimate_buy_execution(&self, notional_usdt: f64) -> Option<ExecutionEstimate> {
84 let mid = self.mid_price()?;
85 if mid <= 0.0 {
86 return None;
87 }
88 let mut remaining = notional_usdt;
89 let mut filled_value = 0.0;
90 let mut filled_qty = 0.0;
91 for level in &self.asks {
92 let level_value = level.value();
93 if remaining <= 0.0 {
94 break;
95 }
96 let take_value = level_value.min(remaining);
97 let take_qty = if level.price > 0.0 {
98 take_value / level.price
99 } else {
100 0.0
101 };
102 filled_value += take_value;
103 filled_qty += take_qty;
104 remaining -= take_value;
105 }
106 let fillable = remaining <= 0.01;
107 let vwap = if filled_qty > 0.0 {
108 filled_value / filled_qty
109 } else {
110 mid
111 };
112 let slippage_bps = (vwap - mid) / mid * 10_000.0;
113 Some(ExecutionEstimate {
114 notional_usdt,
115 side: ExecutionSide::Buy,
116 vwap,
117 slippage_bps,
118 fillable,
119 })
120 }
121
122 pub fn estimate_sell_execution(&self, notional_usdt: f64) -> Option<ExecutionEstimate> {
124 let mid = self.mid_price()?;
125 if mid <= 0.0 {
126 return None;
127 }
128 let mut remaining = notional_usdt;
129 let mut filled_value = 0.0;
130 let mut filled_qty = 0.0;
131 for level in &self.bids {
132 if remaining <= 0.0 {
133 break;
134 }
135 let level_value = level.value();
136 let take_value = level_value.min(remaining);
137 let take_qty = if level.price > 0.0 {
138 take_value / level.price
139 } else {
140 0.0
141 };
142 filled_value += take_value;
143 filled_qty += take_qty;
144 remaining -= take_value;
145 }
146 let fillable = remaining <= 0.01;
147 let vwap = if filled_qty > 0.0 {
148 filled_value / filled_qty
149 } else {
150 mid
151 };
152 let slippage_bps = (mid - vwap) / mid * 10_000.0;
153 Some(ExecutionEstimate {
154 notional_usdt,
155 side: ExecutionSide::Sell,
156 vwap,
157 slippage_bps,
158 fillable,
159 })
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq)]
165pub enum ExecutionSide {
166 Buy,
167 Sell,
168}
169
170#[derive(Debug, Clone)]
172pub struct ExecutionEstimate {
173 pub notional_usdt: f64,
174 pub side: ExecutionSide,
175 pub vwap: f64,
176 pub slippage_bps: f64,
177 pub fillable: bool,
178}
179
180#[derive(Debug, Clone)]
186pub struct HealthThresholds {
187 pub peg_target: f64,
189 pub peg_range: f64,
191 pub min_levels: usize,
193 pub min_depth: f64,
195 pub min_bid_ask_ratio: f64,
197 pub max_bid_ask_ratio: f64,
199}
200
201impl Default for HealthThresholds {
202 fn default() -> Self {
203 Self {
204 peg_target: 1.0,
205 peg_range: 0.001,
206 min_levels: 6,
207 min_depth: 3000.0,
208 min_bid_ask_ratio: 0.2,
209 max_bid_ask_ratio: 5.0,
210 }
211 }
212}
213
214#[derive(Debug, Clone, PartialEq)]
216pub enum HealthCheck {
217 Pass(String),
218 Fail(String),
219}
220
221#[derive(Debug, Clone)]
223pub struct MarketSummary {
224 pub pair: String,
226 pub peg_target: f64,
228 pub best_bid: Option<f64>,
230 pub best_ask: Option<f64>,
232 pub mid_price: Option<f64>,
234 pub spread: Option<f64>,
236 pub volume_24h: Option<f64>,
238 pub execution_10k_buy: Option<ExecutionEstimate>,
240 pub execution_10k_sell: Option<ExecutionEstimate>,
242 pub asks: Vec<OrderBookLevel>,
244 pub bids: Vec<OrderBookLevel>,
246 pub ask_outliers: usize,
248 pub bid_outliers: usize,
250 pub ask_depth: f64,
252 pub bid_depth: f64,
254 pub checks: Vec<HealthCheck>,
256 pub healthy: bool,
258}
259
260impl MarketSummary {
261 pub fn from_order_book(
264 book: &OrderBook,
265 peg_target: f64,
266 thresholds: &HealthThresholds,
267 volume_24h: Option<f64>,
268 ) -> Self {
269 let price_lo = peg_target - thresholds.peg_range * 5.0;
270 let price_hi = peg_target + thresholds.peg_range * 5.0;
271
272 let asks: Vec<OrderBookLevel> = book
273 .asks
274 .iter()
275 .filter(|l| l.price <= price_hi)
276 .cloned()
277 .collect();
278 let bids: Vec<OrderBookLevel> = book
279 .bids
280 .iter()
281 .filter(|l| l.price >= price_lo)
282 .cloned()
283 .collect();
284
285 let ask_outliers = book.asks.len().saturating_sub(asks.len());
286 let bid_outliers = book.bids.len().saturating_sub(bids.len());
287
288 let ask_depth: f64 = asks.iter().map(OrderBookLevel::value).sum();
289 let bid_depth: f64 = bids.iter().map(OrderBookLevel::value).sum();
290
291 let mut checks = Vec::new();
292
293 let below_peg: Vec<_> = asks.iter().filter(|a| a.price < peg_target).collect();
295 if below_peg.is_empty() {
296 checks.push(HealthCheck::Pass("No sells below peg".to_string()));
297 } else {
298 let usdt = below_peg.iter().map(|a| a.value()).sum::<f64>();
299 checks.push(HealthCheck::Fail(format!(
300 "{} sell(s) below peg ({:.0} USDT)",
301 below_peg.len(),
302 usdt
303 )));
304 }
305
306 let ratio = if ask_depth > 0.0 {
308 bid_depth / ask_depth
309 } else {
310 0.0
311 };
312 if ratio < thresholds.min_bid_ask_ratio || ratio > thresholds.max_bid_ask_ratio {
313 checks.push(HealthCheck::Fail(format!("Bid/Ask ratio: {:.2}x", ratio)));
314 } else {
315 checks.push(HealthCheck::Pass(format!("Bid/Ask ratio: {:.2}x", ratio)));
316 }
317
318 if bids.len() < thresholds.min_levels {
320 checks.push(HealthCheck::Fail(format!(
321 "Bid levels: {} < {} minimum",
322 bids.len(),
323 thresholds.min_levels
324 )));
325 }
326
327 if bid_depth < thresholds.min_depth {
329 checks.push(HealthCheck::Fail(format!(
330 "Bid depth: {:.0} USDT < {:.0} USDT minimum",
331 bid_depth, thresholds.min_depth
332 )));
333 }
334
335 if asks.len() < thresholds.min_levels {
337 checks.push(HealthCheck::Fail(format!(
338 "Ask levels: {} < {} minimum",
339 asks.len(),
340 thresholds.min_levels
341 )));
342 }
343
344 if ask_depth < thresholds.min_depth {
346 checks.push(HealthCheck::Fail(format!(
347 "Ask depth: {:.0} USDT < {:.0} USDT minimum",
348 ask_depth, thresholds.min_depth
349 )));
350 }
351
352 let healthy = checks.iter().all(|c| matches!(c, HealthCheck::Pass(_)));
353
354 let execution_10k_buy = book.estimate_buy_execution(10_000.0);
355 let execution_10k_sell = book.estimate_sell_execution(10_000.0);
356
357 Self {
358 pair: book.pair.clone(),
359 peg_target,
360 best_bid: book.best_bid(),
361 best_ask: book.best_ask(),
362 mid_price: book.mid_price(),
363 spread: book.spread(),
364 volume_24h,
365 execution_10k_buy,
366 execution_10k_sell,
367 asks,
368 bids,
369 ask_outliers,
370 bid_outliers,
371 ask_depth,
372 bid_depth,
373 checks,
374 healthy,
375 }
376 }
377
378 pub fn format_text(&self, venue_or_chain: Option<&str>) -> String {
381 let mut out = String::new();
382
383 let title = match venue_or_chain {
384 Some(c) => format!("{} Market Summary ({})", self.pair, c),
385 None => format!("{} Market Summary", self.pair),
386 };
387 out.push_str(&format!("\n {}\n", title));
388 out.push_str(&format!(" {}\n", "─".repeat(44)));
389 if let Some(c) = venue_or_chain {
390 out.push_str(&format!(" Venue: {}\n", c));
391 }
392 out.push_str(&format!(" Peg Target: {:.4}\n", self.peg_target));
393
394 if let Some(bb) = self.best_bid {
395 let pct = (bb - self.peg_target) / self.peg_target * 100.0;
396 out.push_str(&format!(" Best Bid: {:.4} ({:+.3}%)\n", bb, pct));
397 } else {
398 out.push_str(" Best Bid: NONE\n");
399 }
400
401 if let Some(ba) = self.best_ask {
402 let pct = (ba - self.peg_target) / self.peg_target * 100.0;
403 out.push_str(&format!(" Best Ask: {:.4} ({:+.3}%)\n", ba, pct));
404 } else {
405 out.push_str(" Best Ask: NONE\n");
406 }
407
408 if let Some(mid) = self.mid_price {
409 let pct = (mid - self.peg_target) / self.peg_target * 100.0;
410 out.push_str(&format!(" Mid Price: {:.4} ({:+.3}%)\n", mid, pct));
411 }
412 if let (Some(spread), Some(mid)) = (self.spread, self.mid_price)
413 && mid > 0.0
414 {
415 out.push_str(&format!(
416 " Spread: {:.4} ({:.3}%)\n",
417 spread,
418 spread / mid * 100.0
419 ));
420 }
421
422 if let Some(v) = self.volume_24h {
423 out.push_str(&format!(" Volume (24h): {:>12.0} USDT\n", v));
424 }
425
426 if let Some(e) = &self.execution_10k_buy {
427 let msg = if e.fillable {
428 format!("10k buy: ~{:.2} bps slippage", e.slippage_bps)
429 } else {
430 "10k buy: insufficient liquidity".to_string()
431 };
432 out.push_str(&format!(" Execution: {}\n", msg));
433 }
434 if let Some(e) = &self.execution_10k_sell {
435 let msg = if e.fillable {
436 format!("10k sell: ~{:.2} bps slippage", e.slippage_bps)
437 } else {
438 "10k sell: insufficient liquidity".to_string()
439 };
440 out.push_str(&format!(" Execution: {}\n", msg));
441 }
442
443 out.push('\n');
444
445 let mut ask_label = format!(
447 " Ask Side: {:>3} levels {:>10.0} USDT depth",
448 self.asks.len(),
449 self.ask_depth
450 );
451 if self.ask_outliers > 0 {
452 ask_label.push_str(&format!(" (+{} outlier(s) excluded)", self.ask_outliers));
453 }
454 ask_label.push('\n');
455 out.push_str(&ask_label);
456
457 let base_symbol = self.pair.split('/').next().unwrap_or("BASE");
458 for level in self.asks.iter().take(8) {
459 let flag = if level.price < self.peg_target {
460 " ⚠ BELOW PEG"
461 } else {
462 ""
463 };
464 out.push_str(&format!(
465 " {:.4} {:>10.2} {} {:>10.2} USDT{}\n",
466 level.price,
467 level.quantity,
468 base_symbol,
469 level.value(),
470 flag
471 ));
472 }
473 if self.asks.len() > 8 {
474 out.push_str(&format!(" ... +{} more levels\n", self.asks.len() - 8));
475 }
476 out.push('\n');
477
478 let mut bid_label = format!(
480 " Bid Side: {:>3} levels {:>10.0} USDT depth",
481 self.bids.len(),
482 self.bid_depth
483 );
484 if self.bid_outliers > 0 {
485 bid_label.push_str(&format!(" (+{} outlier(s) excluded)", self.bid_outliers));
486 }
487 bid_label.push('\n');
488 out.push_str(&bid_label);
489
490 for level in self.bids.iter().take(8) {
491 out.push_str(&format!(
492 " {:.4} {:>10.2} {} {:>10.2} USDT\n",
493 level.price,
494 level.quantity,
495 base_symbol,
496 level.value()
497 ));
498 }
499 if self.bids.len() > 8 {
500 out.push_str(&format!(" ... +{} more levels\n", self.bids.len() - 8));
501 }
502 out.push('\n');
503
504 for check in &self.checks {
506 match check {
507 HealthCheck::Pass(msg) => {
508 out.push_str(&format!(" ✓ {}\n", msg));
509 }
510 HealthCheck::Fail(msg) => {
511 out.push_str(&format!(" ⚠ {}\n", msg));
512 }
513 }
514 }
515 out.push('\n');
516
517 let fail_count = self
518 .checks
519 .iter()
520 .filter(|c| matches!(c, HealthCheck::Fail(_)))
521 .count();
522 if self.healthy {
523 out.push_str(" Book: ✓ HEALTHY\n");
524 } else {
525 out.push_str(&format!(" Book: ⚠ {} issue(s) found\n", fail_count));
526 }
527 out.push('\n');
528
529 out
530 }
531}
532
533#[async_trait]
539pub trait OrderBookClient: Send + Sync {
540 async fn fetch_order_book(&self, pair_symbol: &str) -> Result<OrderBook>;
542}
543
544#[derive(Debug, Clone)]
552pub struct BiconomyClient {
553 base_url: String,
554 client: Client,
555}
556
557impl BiconomyClient {
558 pub fn new(base_url: impl Into<String>) -> Self {
560 Self {
561 base_url: base_url.into().trim_end_matches('/').to_string(),
562 client: Client::builder()
563 .timeout(Duration::from_secs(15))
564 .build()
565 .expect("reqwest client build"),
566 }
567 }
568
569 pub fn default_url() -> Self {
571 Self::new("https://api.biconomy.com")
572 }
573}
574
575#[derive(Debug, Deserialize)]
576struct BiconomyDepthResponse {
577 asks: Option<Vec<[String; 2]>>,
578 bids: Option<Vec<[String; 2]>>,
579}
580
581#[async_trait]
582impl OrderBookClient for BiconomyClient {
583 async fn fetch_order_book(&self, pair_symbol: &str) -> Result<OrderBook> {
584 let url = format!(
585 "{}/api/v1/depth?symbol={}",
586 self.base_url,
587 urlencoding::encode(pair_symbol)
588 );
589
590 let resp = self.client.get(&url).send().await?;
591 if !resp.status().is_success() {
592 return Err(ScopeError::Chain(format!(
593 "Biconomy API error: HTTP {}",
594 resp.status()
595 )));
596 }
597
598 let raw: BiconomyDepthResponse = resp
599 .json()
600 .await
601 .map_err(|e| ScopeError::Chain(format!("Biconomy depth parse error: {}", e)))?;
602
603 let asks = raw.asks.unwrap_or_default();
604 let bids = raw.bids.unwrap_or_default();
605
606 let parse_level = |p: &str, q: &str| -> Result<OrderBookLevel> {
607 let price = p
608 .parse::<f64>()
609 .map_err(|_| ScopeError::Chain(format!("Invalid price: {}", p)))?;
610 let quantity = q
611 .parse::<f64>()
612 .map_err(|_| ScopeError::Chain(format!("Invalid quantity: {}", q)))?;
613 Ok(OrderBookLevel { price, quantity })
614 };
615
616 let mut ask_levels = Vec::with_capacity(asks.len());
617 for [p, q] in &asks {
618 ask_levels.push(parse_level(p, q)?);
619 }
620 ask_levels.sort_by(|a, b| {
621 a.price
622 .partial_cmp(&b.price)
623 .unwrap_or(std::cmp::Ordering::Equal)
624 });
625
626 let mut bid_levels = Vec::with_capacity(bids.len());
627 for [p, q] in &bids {
628 bid_levels.push(parse_level(p, q)?);
629 }
630 bid_levels.sort_by(|a, b| {
631 b.price
632 .partial_cmp(&a.price)
633 .unwrap_or(std::cmp::Ordering::Equal)
634 });
635
636 let pair = pair_symbol.replace('_', "/");
637
638 Ok(OrderBook {
639 pair,
640 bids: bid_levels,
641 asks: ask_levels,
642 })
643 }
644}
645
646#[derive(Debug, Clone)]
655pub struct BinanceClient {
656 base_url: String,
657 client: Client,
658}
659
660impl BinanceClient {
661 pub fn new(base_url: impl Into<String>) -> Self {
663 Self {
664 base_url: base_url.into().trim_end_matches('/').to_string(),
665 client: Client::builder()
666 .timeout(Duration::from_secs(15))
667 .build()
668 .expect("reqwest client build"),
669 }
670 }
671
672 pub fn default_url() -> Self {
674 Self::new("https://api.binance.com")
675 }
676
677 pub async fn fetch_24h_volume(&self, pair_symbol: &str) -> Result<Option<f64>> {
679 let url = format!(
680 "{}/api/v3/ticker/24hr?symbol={}",
681 self.base_url,
682 urlencoding::encode(pair_symbol)
683 );
684 let resp = self.client.get(&url).send().await?;
685 if !resp.status().is_success() {
686 return Ok(None);
687 }
688 #[derive(serde::Deserialize)]
689 struct Ticker24hr {
690 #[serde(rename = "quoteVolume")]
691 quote_volume: Option<String>,
692 }
693 let ticker: Ticker24hr = resp
694 .json()
695 .await
696 .map_err(|e| ScopeError::Chain(format!("Binance ticker parse error: {}", e)))?;
697 Ok(ticker.quote_volume.and_then(|s| s.parse::<f64>().ok()))
698 }
699}
700
701#[derive(Debug, Deserialize)]
702struct BinanceDepthResponse {
703 asks: Option<Vec<[String; 2]>>,
704 bids: Option<Vec<[String; 2]>>,
705}
706
707#[async_trait]
708impl OrderBookClient for BinanceClient {
709 async fn fetch_order_book(&self, pair_symbol: &str) -> Result<OrderBook> {
710 let url = format!(
711 "{}/api/v3/depth?symbol={}&limit=100",
712 self.base_url,
713 urlencoding::encode(pair_symbol)
714 );
715
716 let resp = self.client.get(&url).send().await?;
717 if !resp.status().is_success() {
718 return Err(ScopeError::Chain(format!(
719 "Binance API error: HTTP {}",
720 resp.status()
721 )));
722 }
723
724 let raw: BinanceDepthResponse = resp
725 .json()
726 .await
727 .map_err(|e| ScopeError::Chain(format!("Binance depth parse error: {}", e)))?;
728
729 let asks = raw.asks.unwrap_or_default();
730 let bids = raw.bids.unwrap_or_default();
731
732 let parse_level = |p: &str, q: &str| -> Result<OrderBookLevel> {
733 let price = p
734 .parse::<f64>()
735 .map_err(|_| ScopeError::Chain(format!("Invalid price: {}", p)))?;
736 let quantity = q
737 .parse::<f64>()
738 .map_err(|_| ScopeError::Chain(format!("Invalid quantity: {}", q)))?;
739 Ok(OrderBookLevel { price, quantity })
740 };
741
742 let mut ask_levels = Vec::with_capacity(asks.len());
743 for [p, q] in &asks {
744 ask_levels.push(parse_level(p, q)?);
745 }
746 ask_levels.sort_by(|a, b| {
747 a.price
748 .partial_cmp(&b.price)
749 .unwrap_or(std::cmp::Ordering::Equal)
750 });
751
752 let mut bid_levels = Vec::with_capacity(bids.len());
753 for [p, q] in &bids {
754 bid_levels.push(parse_level(p, q)?);
755 }
756 bid_levels.sort_by(|a, b| {
757 b.price
758 .partial_cmp(&a.price)
759 .unwrap_or(std::cmp::Ordering::Equal)
760 });
761
762 let pair = if pair_symbol.len() > 4 && pair_symbol.ends_with("USDT") {
764 format!("{}/USDT", &pair_symbol[..pair_symbol.len() - 4])
765 } else {
766 pair_symbol.to_string()
767 };
768
769 Ok(OrderBook {
770 pair,
771 bids: bid_levels,
772 asks: ask_levels,
773 })
774 }
775}
776
777#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
786pub enum MarketVenue {
787 #[default]
789 Binance,
790
791 Biconomy,
793
794 #[value(name = "eth")]
796 Ethereum,
797
798 Solana,
800}
801
802impl MarketVenue {
803 pub fn is_cex(&self) -> bool {
805 matches!(self, MarketVenue::Binance | MarketVenue::Biconomy)
806 }
807
808 pub fn format_pair(&self, base_symbol: &str) -> String {
810 match self {
811 MarketVenue::Binance => format!("{}USDT", base_symbol),
812 MarketVenue::Biconomy => format!("{}_USDT", base_symbol),
813 MarketVenue::Ethereum | MarketVenue::Solana => format!("{}/USDT", base_symbol),
814 }
815 }
816
817 pub fn create_client(&self) -> Option<Box<dyn OrderBookClient>> {
820 match self {
821 MarketVenue::Binance => Some(Box::new(BinanceClient::default_url())),
822 MarketVenue::Biconomy => {
823 Some(Box::new(BiconomyClient::new("https://api.biconomy.com")))
824 }
825 MarketVenue::Ethereum | MarketVenue::Solana => None,
826 }
827 }
828}
829
830pub fn order_book_from_analytics(
832 _chain: &str,
833 pair: &crate::chains::DexPair,
834 symbol: &str,
835) -> OrderBook {
836 let price = pair.price_usd;
837 let liquidity = pair.liquidity_usd;
838 let bid_price = price * 0.999;
840 let ask_price = price * 1.001;
841 let half_liq = liquidity / 2.0;
842 let bid_qty = if bid_price > 0.0 {
843 half_liq / bid_price
844 } else {
845 0.0
846 };
847 let ask_qty = if ask_price > 0.0 {
848 half_liq / ask_price
849 } else {
850 0.0
851 };
852 OrderBook {
853 pair: format!("{}/USDT", symbol),
854 bids: vec![OrderBookLevel {
855 price: bid_price,
856 quantity: bid_qty,
857 }],
858 asks: vec![OrderBookLevel {
859 price: ask_price,
860 quantity: ask_qty,
861 }],
862 }
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868
869 #[test]
870 fn test_order_book_level_value() {
871 let level = OrderBookLevel {
872 price: 1.0002,
873 quantity: 100.0,
874 };
875 assert!((level.value() - 100.02).abs() < 1e-6);
876 }
877
878 #[test]
879 fn test_order_book_empty() {
880 let book = OrderBook {
881 pair: "PUSD/USDT".to_string(),
882 bids: vec![],
883 asks: vec![],
884 };
885 assert!(book.best_bid().is_none());
886 assert!(book.best_ask().is_none());
887 assert!(book.mid_price().is_none());
888 assert_eq!(book.bid_depth(), 0.0);
889 assert_eq!(book.ask_depth(), 0.0);
890 }
891
892 #[test]
893 fn test_order_book_with_levels() {
894 let book = OrderBook {
895 pair: "PUSD/USDT".to_string(),
896 bids: vec![
897 OrderBookLevel {
898 price: 0.9998,
899 quantity: 100.0,
900 },
901 OrderBookLevel {
902 price: 0.9997,
903 quantity: 50.0,
904 },
905 ],
906 asks: vec![
907 OrderBookLevel {
908 price: 1.0001,
909 quantity: 200.0,
910 },
911 OrderBookLevel {
912 price: 1.0002,
913 quantity: 150.0,
914 },
915 ],
916 };
917 assert_eq!(book.best_bid(), Some(0.9998));
918 assert_eq!(book.best_ask(), Some(1.0001));
919 assert_eq!(book.mid_price(), Some(0.99995));
920 assert!((book.spread().unwrap() - 0.0003).abs() < 1e-10);
921 assert!((book.bid_depth() - 99.98 - 49.985).abs() < 0.01);
922 assert!((book.ask_depth() - 200.02 - 150.03).abs() < 0.01);
923 }
924
925 #[test]
926 fn test_market_summary_from_order_book() {
927 let book = OrderBook {
929 pair: "PUSD/USDT".to_string(),
930 bids: vec![
931 OrderBookLevel {
932 price: 0.9998,
933 quantity: 600.0,
934 },
935 OrderBookLevel {
936 price: 0.9997,
937 quantity: 600.0,
938 },
939 OrderBookLevel {
940 price: 0.9996,
941 quantity: 600.0,
942 },
943 OrderBookLevel {
944 price: 0.9995,
945 quantity: 600.0,
946 },
947 OrderBookLevel {
948 price: 0.9994,
949 quantity: 600.0,
950 },
951 OrderBookLevel {
952 price: 0.9993,
953 quantity: 600.0,
954 },
955 ],
956 asks: vec![
957 OrderBookLevel {
958 price: 1.0001,
959 quantity: 600.0,
960 },
961 OrderBookLevel {
962 price: 1.0002,
963 quantity: 600.0,
964 },
965 OrderBookLevel {
966 price: 1.0003,
967 quantity: 600.0,
968 },
969 OrderBookLevel {
970 price: 1.0004,
971 quantity: 600.0,
972 },
973 OrderBookLevel {
974 price: 1.0005,
975 quantity: 600.0,
976 },
977 OrderBookLevel {
978 price: 1.0006,
979 quantity: 600.0,
980 },
981 ],
982 };
983
984 let thresholds = HealthThresholds::default();
985 let summary = MarketSummary::from_order_book(&book, 1.0, &thresholds, None);
986
987 assert!(summary.healthy);
988 assert_eq!(summary.bids.len(), 6);
989 assert_eq!(summary.asks.len(), 6);
990 assert!(summary.bid_depth > 3000.0);
991 assert!(summary.ask_depth > 3000.0);
992 }
993
994 #[test]
995 fn test_format_text_with_chain() {
996 let book = OrderBook {
997 pair: "PUSD/USDT".to_string(),
998 bids: vec![OrderBookLevel {
999 price: 1.0,
1000 quantity: 100.0,
1001 }],
1002 asks: vec![OrderBookLevel {
1003 price: 1.0,
1004 quantity: 100.0,
1005 }],
1006 };
1007 let summary =
1008 MarketSummary::from_order_book(&book, 1.0, &HealthThresholds::default(), None);
1009 let out = summary.format_text(Some("biconomy"));
1010 assert!(out.contains("biconomy"));
1011 assert!(out.contains("Venue:"));
1012 }
1013
1014 #[test]
1015 fn test_format_text_without_chain() {
1016 let book = OrderBook {
1017 pair: "X/Y".to_string(),
1018 bids: vec![OrderBookLevel {
1019 price: 1.0,
1020 quantity: 10.0,
1021 }],
1022 asks: vec![],
1023 };
1024 let summary =
1025 MarketSummary::from_order_book(&book, 1.0, &HealthThresholds::default(), None);
1026 let out = summary.format_text(None);
1027 assert!(out.contains("X/Y Market Summary"));
1028 assert!(!out.contains("Venue:"));
1029 }
1030
1031 #[test]
1032 fn test_health_check_sells_below_peg() {
1033 let book = OrderBook {
1034 pair: "PUSD/USDT".to_string(),
1035 bids: vec![OrderBookLevel {
1036 price: 0.9995,
1037 quantity: 1000.0,
1038 }],
1039 asks: vec![
1040 OrderBookLevel {
1041 price: 0.9990, quantity: 100.0,
1043 },
1044 OrderBookLevel {
1045 price: 1.0001,
1046 quantity: 500.0,
1047 },
1048 ],
1049 };
1050
1051 let summary =
1052 MarketSummary::from_order_book(&book, 1.0, &HealthThresholds::default(), None);
1053
1054 assert!(!summary.healthy);
1055 let has_fail = summary
1056 .checks
1057 .iter()
1058 .any(|c| matches!(c, HealthCheck::Fail(m) if m.contains("sell")));
1059 assert!(has_fail);
1060 }
1061}