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;
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`.
57pub async fn fetch_summary(
58    venue: &MarketVenue,
59    pair: &Pair,
60    thresholds: &HealthThresholds,
61    dex: &dyn DexAnalyticsSource,
62) -> Result<MarketSummary> {
63    let (book, volume_24h) = fetch_book_and_volume(venue, pair, thresholds, dex).await?;
64    Ok(MarketSummary::from_order_book(
65        &book, thresholds, volume_24h,
66    ))
67}
68
69/// The market summary for a token the caller has already analyzed. The pair
70/// is the token symbol with the venue's default quote: a token symbol is
71/// never split (`WETH` is not `W/ETH`). A DEX venue reads these analytics,
72/// so they must be on its chain.
73pub async fn fetch_token_summary(
74    venue: &MarketVenue,
75    analytics: &TokenAnalytics,
76    thresholds: &HealthThresholds,
77) -> Result<MarketSummary> {
78    let pair = Pair::base_only(&analytics.token.symbol);
79    fetch_summary(venue, &pair, thresholds, &KnownAnalytics(analytics)).await
80}
81
82/// [`fetch_token_summary`] for a venue name: resolves it with
83/// [`MarketVenue::from_name`] (the one unknown-venue error), then fetches.
84pub async fn fetch_token_summary_by_name(
85    venue: &str,
86    analytics: &TokenAnalytics,
87    thresholds: &HealthThresholds,
88) -> Result<MarketSummary> {
89    fetch_token_summary(&MarketVenue::from_name(venue)?, analytics, thresholds).await
90}
91
92fn dex_book_and_volume(
93    venue: DexVenue,
94    analytics: &TokenAnalytics,
95    thresholds: &HealthThresholds,
96) -> Result<(OrderBook, Option<f64>)> {
97    venue.require_chain(&analytics.chain)?;
98    let best = most_liquid(&analytics.dex_pairs).ok_or_else(|| {
99        ScopeError::NotFound(format!(
100            "No DEX pairs found for {} on {}",
101            analytics.token.symbol,
102            venue.chain()
103        ))
104    })?;
105    let book = order_book_from_analytics(venue.chain(), best, &analytics.token.symbol, thresholds);
106    Ok((book, Some(best.volume_24h)))
107}
108
109/// The pool with the most USD liquidity: the deepest book, not the first.
110pub fn most_liquid(pairs: &[DexPair]) -> Option<&DexPair> {
111    pairs
112        .iter()
113        .max_by(|a, b| a.liquidity_usd.total_cmp(&b.liquidity_usd))
114}
115
116async fn cex_book_and_volume(
117    exchange: &ExchangeClient,
118    pair: &Pair,
119) -> Result<(OrderBook, Option<f64>)> {
120    let symbol = exchange.venue_symbol(pair);
121    let book = exchange.fetch_order_book(&symbol).await?;
122    let volume = if exchange.has_ticker() {
123        exchange
124            .fetch_ticker(&symbol)
125            .await
126            .ok()
127            .and_then(|t| t.quote_volume(book.mid_price()))
128    } else {
129        None
130    };
131    Ok((book, volume))
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::chains::Token;
138
139    fn dex_pair(liquidity_usd: f64, volume_24h: f64) -> DexPair {
140        DexPair {
141            dex_name: "DEX".into(),
142            pair_address: "0xpair".into(),
143            base_token: "USDC".into(),
144            quote_token: "USDT".into(),
145            price_usd: 1.0,
146            volume_24h,
147            liquidity_usd,
148            liquidity_base: None,
149            price_change_24h: 0.0,
150            buys_24h: 0,
151            sells_24h: 0,
152            buys_6h: 0,
153            sells_6h: 0,
154            buys_1h: 0,
155            sells_1h: 0,
156            pair_created_at: None,
157            url: None,
158        }
159    }
160
161    fn analytics(chain: &str, pairs: Vec<DexPair>) -> TokenAnalytics {
162        TokenAnalytics {
163            token: Token {
164                contract_address: "0x".into(),
165                symbol: "USDC".into(),
166                name: "USD Coin".into(),
167                decimals: 6,
168            },
169            chain: chain.into(),
170            holders: vec![],
171            total_holders: 0,
172            volume_24h: 0.0,
173            volume_7d: 0.0,
174            price_usd: 1.0,
175            price_change_24h: 0.0,
176            price_change_7d: 0.0,
177            liquidity_usd: 0.0,
178            market_cap: None,
179            fdv: None,
180            total_supply: None,
181            circulating_supply: None,
182            price_history: vec![],
183            volume_history: vec![],
184            holder_history: vec![],
185            dex_pairs: pairs,
186            fetched_at: 0,
187            top_10_concentration: None,
188            top_50_concentration: None,
189            top_100_concentration: None,
190            price_change_6h: 0.0,
191            price_change_1h: 0.0,
192            total_buys_24h: 0,
193            total_sells_24h: 0,
194            total_buys_6h: 0,
195            total_sells_6h: 0,
196            total_buys_1h: 0,
197            total_sells_1h: 0,
198            token_age_hours: None,
199            image_url: None,
200            websites: vec![],
201            socials: vec![],
202            dexscreener_url: None,
203        }
204    }
205
206    #[tokio::test]
207    async fn dex_book_comes_from_the_most_liquid_pool() {
208        let a = analytics(
209            "ethereum",
210            vec![
211                dex_pair(1_000.0, 10.0),
212                dex_pair(50_000.0, 777.0),
213                dex_pair(2_000.0, 20.0),
214            ],
215        );
216        let (book, volume) = fetch_book_and_volume(
217            &MarketVenue::Dex(DexVenue::Ethereum),
218            &Pair::parse("USDC"),
219            &HealthThresholds::default(),
220            &KnownAnalytics(&a),
221        )
222        .await
223        .unwrap();
224        assert_eq!(volume, Some(777.0));
225        assert_eq!(book.pair, "USDC/USD");
226    }
227
228    #[tokio::test]
229    async fn dex_without_pools_is_an_error() {
230        let a = analytics("solana", vec![]);
231        let err = fetch_summary(
232            &MarketVenue::Dex(DexVenue::Solana),
233            &Pair::parse("USDC"),
234            &HealthThresholds::default(),
235            &KnownAnalytics(&a),
236        )
237        .await
238        .unwrap_err();
239        assert!(
240            err.to_string()
241                .contains("No DEX pairs found for USDC on solana"),
242            "{err}"
243        );
244        // Not found, so the web answers 404 (see scope-web `status_for`).
245        assert!(matches!(err, ScopeError::NotFound(_)), "{err:?}");
246    }
247
248    /// One call resolves and fetches: a DEX alias reads the analytics'
249    /// chain, and an unknown name is the one unknown-venue error.
250    #[tokio::test]
251    async fn fetch_token_summary_by_name_resolves_then_fetches() {
252        let a = analytics("ethereum", vec![dex_pair(1_000.0, 10.0)]);
253        let t = HealthThresholds::default();
254        assert!(fetch_token_summary_by_name("eth", &a, &t).await.is_ok());
255        let err = fetch_token_summary_by_name("polygon", &a, &t)
256            .await
257            .unwrap_err();
258        assert!(matches!(err, ScopeError::InvalidInput(_)), "{err:?}");
259        assert!(err.to_string().contains("Unknown venue"), "{err}");
260    }
261
262    /// One chain rule for every caller: analytics from another chain never
263    /// build a DEX venue's book, and the check ignores case.
264    #[tokio::test]
265    async fn dex_analytics_from_another_chain_are_an_error() {
266        let a = analytics("Ethereum", vec![dex_pair(1_000.0, 1.0)]);
267        let venue = MarketVenue::Dex(DexVenue::Solana);
268        let pair = Pair::parse("USDC");
269        let t = HealthThresholds::default();
270        let err = fetch_summary(&venue, &pair, &t, &KnownAnalytics(&a))
271            .await
272            .unwrap_err();
273        assert!(err.to_string().contains("reads chain solana"), "{err}");
274
275        let same_chain = MarketVenue::Dex(DexVenue::Ethereum);
276        assert!(
277            fetch_summary(&same_chain, &pair, &t, &KnownAnalytics(&a))
278                .await
279                .is_ok()
280        );
281    }
282}