Skip to main content

scope/market/
analytics.rs

1//! DEX analytics utilities.
2//!
3//! Builds synthetic order books from DexScreener liquidity data for
4//! Ethereum and Solana DEX venues (#37).
5//!
6//! With the pool's base reserve `x0` and price `P0`, a constant-product
7//! pool (x·y=k) holds `x(P) = x0·√(P0/P)` base at price `P`. One level is
8//! placed at each price step; its quantity is the base the pool buys
9//! (bids) or sells (asks) between two steps. Without the reserve, the old
10//! single-level estimate is kept and marked as such.
11
12use super::health::{DEFAULT_AMM_STEP_PCT, HealthThresholds, TOP10_LEVELS};
13use super::pair::Pair;
14use super::types::{BookSource, OrderBook, OrderBookLevel, PERCENT_PER_RATIO};
15
16/// The most levels per side of a synthetic AMM book. It bounds the work for
17/// a very small step or a very wide band.
18pub const MAX_AMM_LEVELS: usize = 1000;
19
20/// Quote of a synthetic DEX book. Its prices are the pool's USD price
21/// (`DexPair::price_usd`), whatever token the pool pairs with.
22pub const DEX_BOOK_QUOTE: &str = "USD";
23
24/// Spread of the single-level estimate around the price (±0.1%).
25const ESTIMATE_HALF_SPREAD: f64 = 0.001;
26
27/// Builds a synthetic order book from a DEX pair.
28///
29/// Uses the constant-product curve when the pair reports its base reserve,
30/// otherwise a single-level estimate (`BookSource::SyntheticEstimate`).
31pub fn order_book_from_analytics(
32    _chain: &str,
33    pair: &crate::chains::DexPair,
34    symbol: &str,
35    thresholds: &HealthThresholds,
36) -> OrderBook {
37    let label = Pair {
38        base: symbol.to_string(),
39        quote: Some(DEX_BOOK_QUOTE.to_string()),
40    }
41    .to_string();
42    match pair.liquidity_base {
43        Some(x0) if x0.is_finite() && x0 > 0.0 && pair.price_usd > 0.0 => {
44            amm_book(label, pair.price_usd, x0, thresholds)
45        }
46        _ => estimate_book(label, pair.price_usd, pair.liquidity_usd),
47    }
48}
49
50/// Levels per side: enough to cover the outlier band, and at least the
51/// levels the top-10 depth check reads.
52fn amm_levels_per_side(step: f64, t: &HealthThresholds) -> usize {
53    let band = if t.peg_target > 0.0 {
54        t.peg_bands().outlier_width() / t.peg_target
55    } else {
56        0.0
57    };
58    let to_band = if band.is_finite() && band > 0.0 {
59        (band / step - 1e-9).ceil() as usize
60    } else {
61        0
62    };
63    to_band.clamp(TOP10_LEVELS, MAX_AMM_LEVELS)
64}
65
66fn amm_book(label: String, p0: f64, x0: f64, t: &HealthThresholds) -> OrderBook {
67    let step_pct = if t.amm_step_pct.is_finite() && t.amm_step_pct > 0.0 {
68        t.amm_step_pct
69    } else {
70        DEFAULT_AMM_STEP_PCT
71    };
72    let step = step_pct / PERCENT_PER_RATIO;
73    let n = amm_levels_per_side(step, t);
74    let base_at = |p: f64| x0 * (p0 / p).sqrt();
75
76    let mut bids = Vec::with_capacity(n);
77    let mut prev = x0;
78    for i in 1..=n {
79        let p = p0 * (1.0 - i as f64 * step);
80        if p <= 0.0 {
81            break;
82        }
83        let x = base_at(p);
84        bids.push(OrderBookLevel {
85            price: p,
86            quantity: x - prev,
87        });
88        prev = x;
89    }
90
91    let mut asks = Vec::with_capacity(n);
92    let mut prev = x0;
93    for i in 1..=n {
94        let p = p0 * (1.0 + i as f64 * step);
95        let x = base_at(p);
96        asks.push(OrderBookLevel {
97            price: p,
98            quantity: prev - x,
99        });
100        prev = x;
101    }
102
103    OrderBook {
104        pair: label,
105        source: BookSource::SyntheticAmm,
106        bids,
107        asks,
108    }
109}
110
111/// The single-level estimate: half the pool's liquidity at ±0.1%.
112fn estimate_book(label: String, price: f64, liquidity: f64) -> OrderBook {
113    let bid_price = price * (1.0 - ESTIMATE_HALF_SPREAD);
114    let ask_price = price * (1.0 + ESTIMATE_HALF_SPREAD);
115    let half_liq = liquidity / 2.0;
116    let qty = |p: f64| if p > 0.0 { half_liq / p } else { 0.0 };
117    OrderBook {
118        pair: label,
119        source: BookSource::SyntheticEstimate,
120        bids: vec![OrderBookLevel {
121            price: bid_price,
122            quantity: qty(bid_price),
123        }],
124        asks: vec![OrderBookLevel {
125            price: ask_price,
126            quantity: qty(ask_price),
127        }],
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::chains::DexPair;
135
136    fn make_pair(price: f64, liquidity: f64) -> DexPair {
137        DexPair {
138            dex_name: "TestDex".into(),
139            pair_address: "0x0".into(),
140            base_token: "USDC".into(),
141            quote_token: "WETH".into(),
142            price_usd: price,
143            volume_24h: 0.0,
144            liquidity_usd: liquidity,
145            liquidity_base: None,
146            price_change_24h: 0.0,
147            buys_24h: 0,
148            sells_24h: 0,
149            buys_6h: 0,
150            sells_6h: 0,
151            buys_1h: 0,
152            sells_1h: 0,
153            pair_created_at: None,
154            url: None,
155        }
156    }
157
158    use crate::market::health::HealthThresholds;
159    use crate::market::types::BookSource;
160
161    fn amm_pair(price: f64, base_reserve: f64) -> DexPair {
162        DexPair {
163            liquidity_base: Some(base_reserve),
164            ..make_pair(price, 2.0 * price * base_reserve)
165        }
166    }
167
168    fn depth(levels: &[OrderBookLevel], n: usize) -> f64 {
169        levels.iter().take(n).map(OrderBookLevel::value).sum()
170    }
171
172    #[test]
173    fn test_amm_depth_within_one_percent_matches_constant_product() {
174        // #37 acceptance: depth at ±1% equals the closed form of x·y=k.
175        // Bids (pool buys base as the price falls to 0.99·P0) pay
176        // x0·P0·(1 − √0.99); asks (pool sells base up to 1.01·P0) cost
177        // x0·P0·(√1.01 − 1). Ten 0.1% steps reach ±1%.
178        let (p0, x0) = (1.0, 1_000_000.0);
179        let book = order_book_from_analytics(
180            "ethereum",
181            &amm_pair(p0, x0),
182            "USDC",
183            &HealthThresholds::default(),
184        );
185        assert_eq!(book.source, BookSource::SyntheticAmm);
186        let bid_closed = x0 * p0 * (1.0 - 0.99_f64.sqrt());
187        let ask_closed = x0 * p0 * (1.01_f64.sqrt() - 1.0);
188        let (bid, ask) = (depth(&book.bids, 10), depth(&book.asks, 10));
189        assert!(
190            (bid - bid_closed).abs() / bid_closed < 0.005,
191            "bid {} vs {}",
192            bid,
193            bid_closed
194        );
195        assert!(
196            (ask - ask_closed).abs() / ask_closed < 0.005,
197            "ask {} vs {}",
198            ask,
199            ask_closed
200        );
201        // Book order: best first.
202        assert!(book.bids.windows(2).all(|w| w[0].price > w[1].price));
203        assert!(book.asks.windows(2).all(|w| w[0].price < w[1].price));
204        assert!((book.bids[0].price - 0.999).abs() < 1e-12);
205        assert!((book.asks[0].price - 1.001).abs() < 1e-12);
206    }
207
208    #[test]
209    fn test_amm_level_count_covers_band_and_top10() {
210        // Default band is peg ± 0.5% (peg_range 0.001 × 5): 5 steps of 0.1%.
211        // Top-10 depth still needs 10 levels.
212        let t = HealthThresholds::default();
213        let book = order_book_from_analytics("ethereum", &amm_pair(1.0, 1e6), "USDC", &t);
214        assert_eq!((book.bids.len(), book.asks.len()), (10, 10));
215        // A wider band (±5%) needs 50 levels of 0.1%.
216        let wide = HealthThresholds {
217            peg_range: 0.01,
218            ..t.clone()
219        };
220        let book = order_book_from_analytics("ethereum", &amm_pair(1.0, 1e6), "USDC", &wide);
221        assert_eq!(book.asks.len(), 50);
222        // A finer step reaches the same band with more levels.
223        let fine = HealthThresholds {
224            amm_step_pct: 0.05,
225            ..t
226        };
227        let book = order_book_from_analytics("ethereum", &amm_pair(1.0, 1e6), "USDC", &fine);
228        assert_eq!(book.asks.len(), 10);
229        assert!((book.asks[0].price - 1.0005).abs() < 1e-12);
230    }
231
232    #[test]
233    fn test_amm_bad_step_falls_back_to_default() {
234        // A zero, negative or NaN step from a config file must not hang
235        // or divide by zero.
236        for step in [0.0, -1.0, f64::NAN, f64::INFINITY] {
237            let t = HealthThresholds {
238                amm_step_pct: step,
239                ..HealthThresholds::default()
240            };
241            let book = order_book_from_analytics("ethereum", &amm_pair(1.0, 1e6), "USDC", &t);
242            assert_eq!(book.asks.len(), 10, "step {}", step);
243            assert!((book.asks[0].price - 1.001).abs() < 1e-12, "step {}", step);
244        }
245    }
246
247    #[test]
248    fn test_missing_reserves_keep_the_single_level_estimate() {
249        // #37: without reserves keep the old book, and say so.
250        let book = order_book_from_analytics(
251            "ethereum",
252            &make_pair(1.0, 100_000.0),
253            "USDC",
254            &HealthThresholds::default(),
255        );
256        assert_eq!(book.source, BookSource::SyntheticEstimate);
257        assert_eq!((book.bids.len(), book.asks.len()), (1, 1));
258    }
259
260    #[test]
261    fn test_order_book_from_analytics_normal() {
262        let pair = make_pair(1.0, 100_000.0);
263        let book =
264            order_book_from_analytics("ethereum", &pair, "USDC", &HealthThresholds::default());
265        // Prices are USD, so the label says USD, not the pool quote (WETH).
266        assert_eq!(book.pair, "USDC/USD");
267        assert_eq!(book.bids.len(), 1);
268        assert_eq!(book.asks.len(), 1);
269        assert!(book.bids[0].price > 0.0);
270        assert!(book.asks[0].price > 0.0);
271        assert!(book.bids[0].quantity > 0.0);
272        assert!(book.asks[0].quantity > 0.0);
273    }
274
275    #[test]
276    fn test_order_book_from_analytics_zero_price() {
277        // price_usd = 0.0 -> bid_price = 0 and ask_price = 0
278        // -> both qty branches hit the else { 0.0 }
279        let pair = make_pair(0.0, 100_000.0);
280        let book =
281            order_book_from_analytics("ethereum", &pair, "TOKEN", &HealthThresholds::default());
282        assert_eq!(book.bids[0].quantity, 0.0);
283        assert_eq!(book.asks[0].quantity, 0.0);
284    }
285
286    #[test]
287    fn test_order_book_from_analytics_zero_liquidity() {
288        let pair = make_pair(1.0, 0.0);
289        let book = order_book_from_analytics("solana", &pair, "SOL", &HealthThresholds::default());
290        assert_eq!(book.bids[0].quantity, 0.0);
291        assert_eq!(book.asks[0].quantity, 0.0);
292    }
293}