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