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.venue_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!(
238            last.message()
239                .contains("100% of 60 trades are exactly 1000"),
240            "{}",
241            last.message()
242        );
243        assert!(last.message().contains("every 2.0s"), "{}", last.message());
244        let json = summary.to_json();
245        assert_eq!(json["bot_patterns"]["trades_sampled"], 60);
246        assert_eq!(json["bot_patterns"]["findings"][0]["kind"], "repeated_size");
247    }
248
249    #[tokio::test]
250    async fn test_a_failed_trade_feed_is_a_reason_not_an_error() {
251        let mut server = mockito::Server::new_async().await;
252        server
253            .mock("GET", mockito::Matcher::Regex(r"^/api/v3/trades.*".into()))
254            .with_status(500)
255            .create_async()
256            .await;
257        let venue = MarketVenue::Cex(Box::new(crate::market::testing::venue_at(
258            "binance",
259            &server.url(),
260        )));
261        let why = fetch_pattern_trades(&venue, &Pair::parse("USDC"))
262            .await
263            .unwrap_err();
264        assert!(why.starts_with("the trade feed failed:"), "{why}");
265    }
266
267    fn dex_pair(liquidity_usd: f64, volume_24h: f64) -> DexPair {
268        DexPair {
269            dex_name: "DEX".into(),
270            pair_address: "0xpair".into(),
271            base_token: "USDC".into(),
272            quote_token: "USDT".into(),
273            price_usd: 1.0,
274            volume_24h,
275            liquidity_usd,
276            liquidity_base: None,
277            price_change_24h: 0.0,
278            buys_24h: 0,
279            sells_24h: 0,
280            buys_6h: 0,
281            sells_6h: 0,
282            buys_1h: 0,
283            sells_1h: 0,
284            pair_created_at: None,
285            url: None,
286        }
287    }
288
289    fn analytics(chain: &str, pairs: Vec<DexPair>) -> TokenAnalytics {
290        TokenAnalytics {
291            token: Token {
292                contract_address: "0x".into(),
293                symbol: "USDC".into(),
294                name: "USD Coin".into(),
295                decimals: 6,
296            },
297            chain: chain.into(),
298            holders: vec![],
299            total_holders: 0,
300            volume_24h: 0.0,
301            volume_7d: 0.0,
302            price_usd: 1.0,
303            price_change_24h: 0.0,
304            price_change_7d: 0.0,
305            liquidity_usd: 0.0,
306            market_cap: None,
307            fdv: None,
308            total_supply: None,
309            circulating_supply: None,
310            price_history: vec![],
311            volume_history: vec![],
312            holder_history: vec![],
313            dex_pairs: pairs,
314            fetched_at: 0,
315            top_10_concentration: None,
316            top_50_concentration: None,
317            top_100_concentration: None,
318            price_change_6h: 0.0,
319            price_change_1h: 0.0,
320            total_buys_24h: 0,
321            total_sells_24h: 0,
322            total_buys_6h: 0,
323            total_sells_6h: 0,
324            total_buys_1h: 0,
325            total_sells_1h: 0,
326            token_age_hours: None,
327            image_url: None,
328            websites: vec![],
329            socials: vec![],
330            dexscreener_url: None,
331        }
332    }
333
334    #[tokio::test]
335    async fn dex_book_comes_from_the_most_liquid_pool() {
336        let a = analytics(
337            "ethereum",
338            vec![
339                dex_pair(1_000.0, 10.0),
340                dex_pair(50_000.0, 777.0),
341                dex_pair(2_000.0, 20.0),
342            ],
343        );
344        let (book, volume) = fetch_book_and_volume(
345            &MarketVenue::Dex(DexVenue::Ethereum),
346            &Pair::parse("USDC"),
347            &HealthThresholds::default(),
348            &KnownAnalytics(&a),
349        )
350        .await
351        .unwrap();
352        assert_eq!(volume, Some(777.0));
353        assert_eq!(book.pair, "USDC/USD");
354    }
355
356    #[tokio::test]
357    async fn dex_without_pools_is_an_error() {
358        let a = analytics("solana", vec![]);
359        let err = fetch_summary(
360            &MarketVenue::Dex(DexVenue::Solana),
361            &Pair::parse("USDC"),
362            &HealthThresholds::default(),
363            &KnownAnalytics(&a),
364        )
365        .await
366        .unwrap_err();
367        assert!(
368            err.to_string()
369                .contains("No DEX pairs found for USDC on solana"),
370            "{err}"
371        );
372        // Not found, so the web answers 404 (see scope-web `status_for`).
373        assert!(matches!(err, ScopeError::NotFound(_)), "{err:?}");
374    }
375
376    /// One call resolves and fetches: a DEX alias reads the analytics'
377    /// chain, and an unknown name is the one unknown-venue error.
378    #[tokio::test]
379    async fn fetch_token_summary_by_name_resolves_then_fetches() {
380        let a = analytics("ethereum", vec![dex_pair(1_000.0, 10.0)]);
381        let t = HealthThresholds::default();
382        assert!(
383            fetch_token_summary_by_name_with_http("eth", &a, &t, crate::http::test_transport())
384                .await
385                .is_ok()
386        );
387        let err =
388            fetch_token_summary_by_name_with_http("polygon", &a, &t, crate::http::test_transport())
389                .await
390                .unwrap_err();
391        assert!(matches!(err, ScopeError::InvalidInput(_)), "{err:?}");
392        assert!(err.to_string().contains("Unknown venue"), "{err}");
393    }
394
395    /// One chain rule for every caller: analytics from another chain never
396    /// build a DEX venue's book, and the check ignores case.
397    #[tokio::test]
398    async fn dex_analytics_from_another_chain_are_an_error() {
399        let a = analytics("Ethereum", vec![dex_pair(1_000.0, 1.0)]);
400        let venue = MarketVenue::Dex(DexVenue::Solana);
401        let pair = Pair::parse("USDC");
402        let t = HealthThresholds::default();
403        let err = fetch_summary(&venue, &pair, &t, &KnownAnalytics(&a))
404            .await
405            .unwrap_err();
406        assert!(err.to_string().contains("reads chain solana"), "{err}");
407
408        let same_chain = MarketVenue::Dex(DexVenue::Ethereum);
409        assert!(
410            fetch_summary(&same_chain, &pair, &t, &KnownAnalytics(&a))
411                .await
412                .is_ok()
413        );
414    }
415}