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, Trade};
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(
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
71pub 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
90pub 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#[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
123pub 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
152pub 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 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 assert!(matches!(err, ScopeError::NotFound(_)), "{err:?}");
374 }
375
376 #[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 #[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}