Skip to main content

scope/market/
orderbook.rs

1//! Order book fetching and peg/health analysis.
2//!
3//! Supports configurable exchange APIs and health check thresholds for
4//! stablecoin market monitoring.
5
6use crate::error::{Result, ScopeError};
7use async_trait::async_trait;
8use reqwest::Client;
9use serde::Deserialize;
10use std::time::Duration;
11
12// =============================================================================
13// Types
14// =============================================================================
15
16/// A single price level in the order book.
17#[derive(Debug, Clone, PartialEq)]
18pub struct OrderBookLevel {
19    /// Price (e.g., 1.0001)
20    pub price: f64,
21    /// Quantity in base asset (e.g., PUSD)
22    pub quantity: f64,
23}
24
25impl OrderBookLevel {
26    /// Value in quote asset (price × quantity, e.g., USDT).
27    #[inline]
28    pub fn value(&self) -> f64 {
29        self.price * self.quantity
30    }
31}
32
33/// Full order book snapshot with bids and asks.
34#[derive(Debug, Clone)]
35pub struct OrderBook {
36    /// Trading pair label (e.g., "PUSD/USDT").
37    pub pair: String,
38    /// Bids sorted by price descending (best bid first).
39    pub bids: Vec<OrderBookLevel>,
40    /// Asks sorted by price ascending (best ask first).
41    pub asks: Vec<OrderBookLevel>,
42}
43
44impl OrderBook {
45    /// Best bid price, or None if empty.
46    pub fn best_bid(&self) -> Option<f64> {
47        self.bids.first().map(|l| l.price)
48    }
49
50    /// Best ask price, or None if empty.
51    pub fn best_ask(&self) -> Option<f64> {
52        self.asks.first().map(|l| l.price)
53    }
54
55    /// Mid price between best bid and ask.
56    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    /// Spread (ask - bid).
64    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    /// Total bid depth in quote terms (sum of price × quantity).
72    pub fn bid_depth(&self) -> f64 {
73        self.bids.iter().map(OrderBookLevel::value).sum()
74    }
75
76    /// Total ask depth in quote terms.
77    pub fn ask_depth(&self) -> f64 {
78        self.asks.iter().map(OrderBookLevel::value).sum()
79    }
80
81    /// Estimate slippage for buying a given USDT notional by walking the ask side.
82    /// Returns (vwap, slippage_bps) if fillable, or None if insufficient liquidity.
83    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    /// Estimate slippage for selling (hitting bids) a given USDT notional.
123    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/// Side of execution (buy = hit asks, sell = hit bids).
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub enum ExecutionSide {
166    Buy,
167    Sell,
168}
169
170/// Result of execution simulation for a given notional size.
171#[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/// Health check thresholds for order book validation.
181///
182/// Default values (min_levels=6, min_depth=3000, peg_range=0.001) originated from
183/// the PUSD Hummingbot market-making config. Override via CLI (`--min-levels`,
184/// `--min-depth`, `--peg-range`, `--min-bid-ask-ratio`, `--max-bid-ask-ratio`).
185#[derive(Debug, Clone)]
186pub struct HealthThresholds {
187    /// Peg target (e.g., 1.0 for USD stablecoins).
188    pub peg_target: f64,
189    /// Price range for "near peg" orders (outliers excluded outside peg ± range×5).
190    pub peg_range: f64,
191    /// Minimum levels per side.
192    pub min_levels: usize,
193    /// Minimum depth per side in quote terms (e.g., USDT).
194    pub min_depth: f64,
195    /// Bid/ask ratio below which to warn (bid side thin).
196    pub min_bid_ask_ratio: f64,
197    /// Bid/ask ratio above which to warn (ask side thin).
198    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/// Outcome of a single health check.
215#[derive(Debug, Clone, PartialEq)]
216pub enum HealthCheck {
217    Pass(String),
218    Fail(String),
219}
220
221/// Aggregated market summary with order book snapshot and health results.
222#[derive(Debug, Clone)]
223pub struct MarketSummary {
224    /// Pair label (e.g., "PUSD/USDT").
225    pub pair: String,
226    /// Peg target.
227    pub peg_target: f64,
228    /// Best bid (raw, no outlier filtering).
229    pub best_bid: Option<f64>,
230    /// Best ask.
231    pub best_ask: Option<f64>,
232    /// Mid price.
233    pub mid_price: Option<f64>,
234    /// Spread.
235    pub spread: Option<f64>,
236    /// 24h volume in quote (e.g. USDT) if available.
237    pub volume_24h: Option<f64>,
238    /// Execution estimate for 10k USDT buy (slippage).
239    pub execution_10k_buy: Option<ExecutionEstimate>,
240    /// Execution estimate for 10k USDT sell (slippage).
241    pub execution_10k_sell: Option<ExecutionEstimate>,
242    /// Asks within peg range (for display).
243    pub asks: Vec<OrderBookLevel>,
244    /// Bids within peg range.
245    pub bids: Vec<OrderBookLevel>,
246    /// Count of ask levels excluded as outliers.
247    pub ask_outliers: usize,
248    /// Count of bid levels excluded as outliers.
249    pub bid_outliers: usize,
250    /// Ask depth (quote) within range.
251    pub ask_depth: f64,
252    /// Bid depth (quote) within range.
253    pub bid_depth: f64,
254    /// Health check results.
255    pub checks: Vec<HealthCheck>,
256    /// Overall healthy (no failures).
257    pub healthy: bool,
258}
259
260impl MarketSummary {
261    /// Build summary from order book with given peg and thresholds.
262    /// Optionally includes 24h volume (from venue ticker) and execution estimates for 10k USDT.
263    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        // Peg safety: sells below peg
294        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        // Bid/ask ratio
307        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        // Bid levels
319        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        // Bid depth
328        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        // Ask levels
336        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        // Ask depth
345        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    /// Format as human-readable text report.
379    /// When `venue_or_chain` is provided, it is displayed in the header (e.g. binance, ethereum).
380    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        // Ask side
446        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        // Bid side
479        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        // Health checks
505        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// =============================================================================
534// Order Book Client Trait
535// =============================================================================
536
537/// Trait for fetching order book data from exchange APIs.
538#[async_trait]
539pub trait OrderBookClient: Send + Sync {
540    /// Fetch the current order book for the given pair symbol (e.g., PUSD_USDT).
541    async fn fetch_order_book(&self, pair_symbol: &str) -> Result<OrderBook>;
542}
543
544// =============================================================================
545// Biconomy Client
546// =============================================================================
547
548/// Biconomy exchange order book client.
549///
550/// Uses the public depth API: `GET /api/v1/depth?symbol=PAIR_SYMBOL`
551#[derive(Debug, Clone)]
552pub struct BiconomyClient {
553    base_url: String,
554    client: Client,
555}
556
557impl BiconomyClient {
558    /// Create a new Biconomy client with the given API base URL.
559    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    /// Create client with default Biconomy API URL.
570    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// =============================================================================
647// Binance Client
648// =============================================================================
649
650/// Binance Spot order book client.
651///
652/// Uses the public depth API: `GET /api/v3/depth?symbol=SYMBOL`
653/// Symbol format: base + quote with no separator (e.g., USDCUSDT).
654#[derive(Debug, Clone)]
655pub struct BinanceClient {
656    base_url: String,
657    client: Client,
658}
659
660impl BinanceClient {
661    /// Create a new Binance client with the given API base URL.
662    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    /// Create client with default Binance API URL.
673    pub fn default_url() -> Self {
674        Self::new("https://api.binance.com")
675    }
676
677    /// Fetch 24h quote volume (USDT) for the given symbol (e.g. USDCUSDT).
678    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        // Binance symbols are concatenated (USDCUSDT); display as base/quote
763        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// =============================================================================
778// Venue abstraction
779// =============================================================================
780
781/// Supported market venues for order book data.
782///
783/// CEX venues (Binance, Biconomy) use REST depth APIs.
784/// DEX venues (Ethereum, Solana) synthesize depth from DEX liquidity.
785#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
786pub enum MarketVenue {
787    /// Binance Spot (symbol format: USDCUSDT).
788    #[default]
789    Binance,
790
791    /// Biconomy exchange (symbol format: USDC_USDT).
792    Biconomy,
793
794    /// Ethereum DEX liquidity (Uniswap, etc.) via DexScreener.
795    #[value(name = "eth")]
796    Ethereum,
797
798    /// Solana DEX liquidity (Raydium, Orca, etc.) via DexScreener.
799    Solana,
800}
801
802impl MarketVenue {
803    /// Whether this venue uses a CEX REST API (needs pair symbol).
804    pub fn is_cex(&self) -> bool {
805        matches!(self, MarketVenue::Binance | MarketVenue::Biconomy)
806    }
807
808    /// Format the trading pair for CEX venues (base + USDT quote).
809    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    /// Create an OrderBookClient for CEX venues.
818    /// Returns None for DEX venues (use `order_book_from_analytics` instead).
819    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
830/// Builds a synthetic order book from DEX analytics (used for Ethereum/Solana venues).
831pub 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    // Synthetic bid/ask spread ±0.1% around mid
839    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        // Use quantities large enough to exceed min_depth (3000 USDT) per side
928        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, // below peg
1042                    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}