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