1use 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#[async_trait]
20pub trait DexAnalyticsSource: Send + Sync {
21 async fn token_analytics(&self, token: &str, chain: &str) -> Result<TokenAnalytics>;
23}
24
25struct 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
35pub 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
56pub 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
69pub 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
82pub 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
109pub 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 assert!(matches!(err, ScopeError::NotFound(_)), "{err:?}");
246 }
247
248 #[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 #[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}