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