Skip to main content

scope/market/
fetch.rs

1//! The market summary use case: venue + pair -> order book, 24h volume and
2//! [`MarketSummary`]. `scope market summary`, `token-health --with-market`,
3//! `insights` and the web endpoints all call it, so the same input gives
4//! the same request everywhere.
5
6use super::MarketSummary;
7use super::analytics::order_book_from_analytics;
8use super::exchange::ExchangeClient;
9use super::health::HealthThresholds;
10use super::pair::Pair;
11use super::types::{OrderBook, Trade};
12use super::venue::{DexVenue, MarketVenue};
13use crate::chains::{DexPair, TokenAnalytics};
14use crate::error::{Result, ScopeError};
15use async_trait::async_trait;
16
17/// Port: token analytics with DEX pools, for a DEX venue's synthetic book.
18/// Adapters resolve the token (symbol, address or alias) on the chain.
19#[async_trait]
20pub trait DexAnalyticsSource: Send + Sync {
21    /// Analytics for `token` on `chain`.
22    async fn token_analytics(&self, token: &str, chain: &str) -> Result<TokenAnalytics>;
23}
24
25/// A [`DexAnalyticsSource`] that serves analytics the caller already has.
26struct KnownAnalytics<'a>(&'a TokenAnalytics);
27
28#[async_trait]
29impl DexAnalyticsSource for KnownAnalytics<'_> {
30    async fn token_analytics(&self, _token: &str, _chain: &str) -> Result<TokenAnalytics> {
31        Ok(self.0.clone())
32    }
33}
34
35/// Fetches the book and 24h quote volume for `pair` on `venue`.
36///
37/// - DEX: the most liquid pool of `pair.base` on the venue's chain; the
38///   volume is that pool's 24h volume.
39/// - CEX: the venue's order book for the pair; the volume is the ticker's
40///   quote volume, when the venue has a ticker.
41pub async fn fetch_book_and_volume(
42    venue: &MarketVenue,
43    pair: &Pair,
44    thresholds: &HealthThresholds,
45    dex: &dyn DexAnalyticsSource,
46) -> Result<(OrderBook, Option<f64>)> {
47    match venue {
48        MarketVenue::Dex(dex_venue) => {
49            let analytics = dex.token_analytics(&pair.base, dex_venue.chain()).await?;
50            dex_book_and_volume(*dex_venue, &analytics, thresholds)
51        }
52        MarketVenue::Cex(exchange) => cex_book_and_volume(exchange, pair).await,
53    }
54}
55
56/// Fetches the book and volume, then judges it against `thresholds`, with
57/// the bot-pattern advisory from the venue's recent trades.
58pub async fn fetch_summary(
59    venue: &MarketVenue,
60    pair: &Pair,
61    thresholds: &HealthThresholds,
62    dex: &dyn DexAnalyticsSource,
63) -> Result<MarketSummary> {
64    fetch_summary_with_trades(venue, pair, thresholds, dex)
65        .await
66        .map(|(summary, _)| summary)
67}
68
69/// [`fetch_summary`], with the venue's recent trades it read (or why there
70/// are none), so a caller can read them too without a second fetch.
71pub async fn fetch_summary_with_trades(
72    venue: &MarketVenue,
73    pair: &Pair,
74    thresholds: &HealthThresholds,
75    dex: &dyn DexAnalyticsSource,
76) -> Result<(MarketSummary, std::result::Result<Vec<Trade>, String>)> {
77    let (book, volume_24h) = fetch_book_and_volume(venue, pair, thresholds, dex).await?;
78    let mut summary = MarketSummary::from_order_book(&book, thresholds, volume_24h);
79    let trades = fetch_pattern_trades(venue, pair).await;
80    summary.check_bot_patterns(trades.as_deref().map_err(String::as_str));
81    Ok((summary, trades))
82}
83
84/// The venue's recent trades for the bot-pattern advisory
85/// ([`super::patterns::TRADE_SAMPLE`] of them). `Err` says why there are
86/// none: the summary shows it, and it never fails the summary.
87pub async fn fetch_pattern_trades(
88    venue: &MarketVenue,
89    pair: &Pair,
90) -> std::result::Result<Vec<Trade>, String> {
91    match venue {
92        MarketVenue::Dex(_) => Err("a DEX pool has no trade tape here".to_string()),
93        MarketVenue::Cex(exchange) if !exchange.has_trade_history() => {
94            Err("the venue gives no trade feed".to_string())
95        }
96        MarketVenue::Cex(exchange) => exchange
97            .fetch_recent_trades(&exchange.venue_symbol(pair), super::patterns::TRADE_SAMPLE)
98            .await
99            .map_err(|e| format!("the trade feed failed: {e}")),
100    }
101}
102
103/// The market summary for a token the caller has already analyzed. The pair
104/// is the token symbol with the venue's default quote: a token symbol is
105/// never split (`WETH` is not `W/ETH`). A DEX venue reads these analytics,
106/// so they must be on its chain.
107pub async fn fetch_token_summary(
108    venue: &MarketVenue,
109    analytics: &TokenAnalytics,
110    thresholds: &HealthThresholds,
111) -> Result<MarketSummary> {
112    let pair = Pair::base_only(&analytics.token.symbol);
113    fetch_summary(venue, &pair, thresholds, &KnownAnalytics(analytics)).await
114}
115
116/// [`fetch_token_summary`] for a venue name: resolves it with
117/// [`MarketVenue::from_name`] (the one unknown-venue error), then fetches.
118#[deprecated(
119    since = "0.8.0",
120    note = "pass the transport: use `fetch_token_summary_by_name_with_http`"
121)]
122pub async fn fetch_token_summary_by_name(
123    venue: &str,
124    analytics: &TokenAnalytics,
125    thresholds: &HealthThresholds,
126) -> Result<MarketSummary> {
127    fetch_token_summary_by_name_with_http(
128        venue,
129        analytics,
130        thresholds,
131        crate::http::shared::fallback(),
132    )
133    .await
134}
135
136/// [`fetch_token_summary_by_name`], sending a CEX venue's requests through
137/// `http`.
138pub async fn fetch_token_summary_by_name_with_http(
139    venue: &str,
140    analytics: &TokenAnalytics,
141    thresholds: &HealthThresholds,
142    http: std::sync::Arc<dyn crate::http::HttpClient>,
143) -> Result<MarketSummary> {
144    let venue = MarketVenue::from_name_with_http(venue, http)?;
145    fetch_token_summary(&venue, analytics, thresholds).await
146}
147
148fn dex_book_and_volume(
149    venue: DexVenue,
150    analytics: &TokenAnalytics,
151    thresholds: &HealthThresholds,
152) -> Result<(OrderBook, Option<f64>)> {
153    venue.require_chain(&analytics.chain)?;
154    let best = most_liquid(&analytics.dex_pairs).ok_or_else(|| {
155        ScopeError::NotFound(format!(
156            "No DEX pairs found for {} on {}",
157            analytics.token.symbol,
158            venue.chain()
159        ))
160    })?;
161    let book = order_book_from_analytics(venue.chain(), best, &analytics.token.symbol, thresholds);
162    Ok((book, Some(best.volume_24h)))
163}
164
165/// The pool with the most USD liquidity: the deepest book, not the first.
166pub fn most_liquid(pairs: &[DexPair]) -> Option<&DexPair> {
167    pairs
168        .iter()
169        .max_by(|a, b| a.liquidity_usd.total_cmp(&b.liquidity_usd))
170}
171
172async fn cex_book_and_volume(
173    exchange: &ExchangeClient,
174    pair: &Pair,
175) -> Result<(OrderBook, Option<f64>)> {
176    let symbol = exchange.checked_symbol(pair)?;
177    let book = exchange.fetch_order_book(&symbol).await?;
178    let volume = if exchange.has_ticker() {
179        exchange
180            .fetch_ticker(&symbol)
181            .await
182            .ok()
183            .and_then(|t| t.quote_volume(book.mid_price()))
184    } else {
185        None
186    };
187    Ok((book, volume))
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::chains::Token;
194    use crate::market::types::{BookSource, OrderBookLevel};
195
196    /// Binance's trades endpoint on a mock server, with `n` trades of one
197    /// size every 2s: a bot's tape.
198    async fn binance_with_bot_tape(n: usize) -> (mockito::ServerGuard, MarketVenue) {
199        let mut server = mockito::Server::new_async().await;
200        let trades: Vec<String> = (0..n)
201            .map(|i| {
202                format!(
203                    r#"{{"id":{i},"price":"1.0001","qty":"1000.00","quoteQty":"1000.1","time":{},"isBuyerMaker":{}}}"#,
204                    1_700_000_000_000u64 + i as u64 * 2_000,
205                    i % 2 == 0
206                )
207            })
208            .collect();
209        server
210            .mock("GET", mockito::Matcher::Regex(r"^/api/v3/trades.*".into()))
211            .with_header("content-type", "application/json")
212            .with_body(format!("[{}]", trades.join(",")))
213            .create_async()
214            .await;
215        let venue = MarketVenue::Cex(Box::new(crate::market::testing::venue_at(
216            "binance",
217            &server.url(),
218        )));
219        (server, venue)
220    }
221
222    #[tokio::test]
223    async fn test_a_venue_tape_feeds_the_bot_pattern_advisory() {
224        let (_server, venue) = binance_with_bot_tape(60).await;
225        let trades = fetch_pattern_trades(&venue, &Pair::parse("USDC"))
226            .await
227            .unwrap();
228        assert_eq!(trades.len(), 60);
229        let book = OrderBook {
230            pair: "USDC/USDT".into(),
231            source: BookSource::Exchange,
232            bids: vec![OrderBookLevel {
233                price: 0.9999,
234                quantity: 100.0,
235            }],
236            asks: vec![OrderBookLevel {
237                price: 1.0001,
238                quantity: 120.0,
239            }],
240        };
241        let mut summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
242        let healthy = summary.healthy;
243        summary.check_bot_patterns(Ok(&trades));
244        assert_eq!(
245            summary.healthy, healthy,
246            "an advisory never changes healthy"
247        );
248        let last = summary.checks.last().unwrap();
249        assert_eq!(last.status(), "warn", "{}", last.message());
250        assert_eq!(
251            last.message(),
252            "Bot patterns: 3 signs of market-making bots (repeated size, metronome, ping-pong)"
253        );
254        let evidence = summary.bot_patterns.as_ref().unwrap().evidence().join("\n");
255        assert!(
256            evidence.contains("Repeated size: 100% of 60 trades are exactly 1000"),
257            "{evidence}"
258        );
259        assert!(evidence.contains("every 2.0s"), "{evidence}");
260        let json = summary.to_json();
261        assert_eq!(json["bot_patterns"]["trades_sampled"], 60);
262        assert_eq!(json["bot_patterns"]["findings"][0]["kind"], "repeated_size");
263    }
264
265    #[tokio::test]
266    async fn test_a_failed_trade_feed_is_a_reason_not_an_error() {
267        let mut server = mockito::Server::new_async().await;
268        server
269            .mock("GET", mockito::Matcher::Regex(r"^/api/v3/trades.*".into()))
270            .with_status(500)
271            .create_async()
272            .await;
273        let venue = MarketVenue::Cex(Box::new(crate::market::testing::venue_at(
274            "binance",
275            &server.url(),
276        )));
277        let why = fetch_pattern_trades(&venue, &Pair::parse("USDC"))
278            .await
279            .unwrap_err();
280        assert!(why.starts_with("the trade feed failed:"), "{why}");
281    }
282
283    fn dex_pair(liquidity_usd: f64, volume_24h: f64) -> DexPair {
284        DexPair {
285            dex_name: "DEX".into(),
286            pair_address: "0xpair".into(),
287            base_token: "USDC".into(),
288            quote_token: "USDT".into(),
289            price_usd: 1.0,
290            volume_24h,
291            liquidity_usd,
292            liquidity_base: None,
293            price_change_24h: 0.0,
294            buys_24h: 0,
295            sells_24h: 0,
296            buys_6h: 0,
297            sells_6h: 0,
298            buys_1h: 0,
299            sells_1h: 0,
300            pair_created_at: None,
301            url: None,
302        }
303    }
304
305    fn analytics(chain: &str, pairs: Vec<DexPair>) -> TokenAnalytics {
306        TokenAnalytics {
307            token: Token {
308                contract_address: "0x".into(),
309                symbol: "USDC".into(),
310                name: "USD Coin".into(),
311                decimals: 6,
312            },
313            chain: chain.into(),
314            holders: vec![],
315            total_holders: 0,
316            volume_24h: 0.0,
317            volume_7d: 0.0,
318            price_usd: 1.0,
319            price_change_24h: 0.0,
320            price_change_7d: 0.0,
321            liquidity_usd: 0.0,
322            market_cap: None,
323            fdv: None,
324            total_supply: None,
325            circulating_supply: None,
326            price_history: vec![],
327            volume_history: vec![],
328            holder_history: vec![],
329            dex_pairs: pairs,
330            fetched_at: 0,
331            top_10_concentration: None,
332            top_50_concentration: None,
333            top_100_concentration: None,
334            price_change_6h: 0.0,
335            price_change_1h: 0.0,
336            total_buys_24h: 0,
337            total_sells_24h: 0,
338            total_buys_6h: 0,
339            total_sells_6h: 0,
340            total_buys_1h: 0,
341            total_sells_1h: 0,
342            token_age_hours: None,
343            image_url: None,
344            websites: vec![],
345            socials: vec![],
346            dexscreener_url: None,
347        }
348    }
349
350    #[tokio::test]
351    async fn dex_book_comes_from_the_most_liquid_pool() {
352        let a = analytics(
353            "ethereum",
354            vec![
355                dex_pair(1_000.0, 10.0),
356                dex_pair(50_000.0, 777.0),
357                dex_pair(2_000.0, 20.0),
358            ],
359        );
360        let (book, volume) = fetch_book_and_volume(
361            &MarketVenue::Dex(DexVenue::Ethereum),
362            &Pair::parse("USDC"),
363            &HealthThresholds::default(),
364            &KnownAnalytics(&a),
365        )
366        .await
367        .unwrap();
368        assert_eq!(volume, Some(777.0));
369        assert_eq!(book.pair, "USDC/USD");
370    }
371
372    #[tokio::test]
373    async fn dex_without_pools_is_an_error() {
374        let a = analytics("solana", vec![]);
375        let err = fetch_summary(
376            &MarketVenue::Dex(DexVenue::Solana),
377            &Pair::parse("USDC"),
378            &HealthThresholds::default(),
379            &KnownAnalytics(&a),
380        )
381        .await
382        .unwrap_err();
383        assert!(
384            err.to_string()
385                .contains("No DEX pairs found for USDC on solana"),
386            "{err}"
387        );
388        // Not found, so the web answers 404 (see scope-web `status_for`).
389        assert!(matches!(err, ScopeError::NotFound(_)), "{err:?}");
390    }
391
392    /// One call resolves and fetches: a DEX alias reads the analytics'
393    /// chain, and an unknown name is the one unknown-venue error.
394    #[tokio::test]
395    async fn fetch_token_summary_by_name_resolves_then_fetches() {
396        let a = analytics("ethereum", vec![dex_pair(1_000.0, 10.0)]);
397        let t = HealthThresholds::default();
398        assert!(
399            fetch_token_summary_by_name_with_http("eth", &a, &t, crate::http::test_transport())
400                .await
401                .is_ok()
402        );
403        let err =
404            fetch_token_summary_by_name_with_http("polygon", &a, &t, crate::http::test_transport())
405                .await
406                .unwrap_err();
407        assert!(matches!(err, ScopeError::InvalidInput(_)), "{err:?}");
408        assert!(err.to_string().contains("Unknown venue"), "{err}");
409    }
410
411    /// One chain rule for every caller: analytics from another chain never
412    /// build a DEX venue's book, and the check ignores case.
413    #[tokio::test]
414    async fn dex_analytics_from_another_chain_are_an_error() {
415        let a = analytics("Ethereum", vec![dex_pair(1_000.0, 1.0)]);
416        let venue = MarketVenue::Dex(DexVenue::Solana);
417        let pair = Pair::parse("USDC");
418        let t = HealthThresholds::default();
419        let err = fetch_summary(&venue, &pair, &t, &KnownAnalytics(&a))
420            .await
421            .unwrap_err();
422        assert!(err.to_string().contains("reads chain solana"), "{err}");
423
424        let same_chain = MarketVenue::Dex(DexVenue::Ethereum);
425        assert!(
426            fetch_summary(&same_chain, &pair, &t, &KnownAnalytics(&a))
427                .await
428                .is_ok()
429        );
430    }
431}