1use super::{Ready, Sources};
8use crate::cli::address::{AddressArgs, analyze_address};
9use crate::cli::crawl::{self, Period};
10use crate::cli::market::CrawlDexSource;
11use crate::cli::output::Output;
12use scope::compliance::datasource::BlockchainDataClient;
13use scope::compliance::risk::RiskEngine;
14use scope::market::{HealthThresholds, MarketSummary, MarketVenue, Pair, fetch_summary};
15use scope::metrics::{MetricGroup, MetricId, describe, sample};
16use scope::watch::{Cell, Preset, RuleStatus, Snapshot, Target, TargetSnapshot, preset};
17
18pub const PRESET_ERROR_LABEL: &str = "market health";
20
21#[derive(Debug, Clone, Default)]
23pub struct Tick {
24 pub snapshot: Snapshot,
25 pub summaries: Vec<Option<MarketSummary>>,
28}
29
30#[derive(Debug, Clone)]
32pub struct TargetReading {
33 pub row: TargetSnapshot,
34 pub summary: Option<MarketSummary>,
35}
36
37pub async fn tick(ready: &Ready, src: Sources<'_>, out: &Output) -> Tick {
39 let mut result = Tick::default();
40 for (t, contract) in ready.list.targets.iter().zip(&ready.contracts) {
41 let reading = read_target(t, contract.as_deref(), src, out).await;
42 result.snapshot.targets.push(reading.row);
43 result.summaries.push(reading.summary);
44 }
45 result
46}
47
48type SourceResult = Result<Vec<(MetricId, Option<f64>)>, String>;
50
51pub async fn read_target(
53 t: &Target,
54 contract: Option<&str>,
55 src: Sources<'_>,
56 out: &Output,
57) -> TargetReading {
58 let thresholds = preset::thresholds(t, &src.config.market.health);
59 let mut cells = Vec::new();
60 let mut summary = None;
61
62 let mut market: Option<SourceResult> = None;
63 if t.needs_market() {
64 match read_market(t, contract, &thresholds, src, out).await {
65 Ok(s) => {
66 if t.preset == Some(Preset::MarketHealth) {
67 cells.extend(preset::cells(&s));
68 }
69 market = Some(Ok(market_values(t, &s, &thresholds)));
70 summary = Some(s);
71 }
72 Err(e) => {
73 if t.preset == Some(Preset::MarketHealth) {
74 cells.push(Cell {
75 label: PRESET_ERROR_LABEL.to_string(),
76 rule: None,
77 value: None,
78 status: RuleStatus::Error(e.clone()),
79 detail: None,
80 });
81 }
82 market = Some(Err(e));
83 }
84 }
85 }
86 let token = if t.needs(MetricGroup::Token) {
87 Some(read_token(t, contract, src, out).await)
88 } else {
89 None
90 };
91 let address = if t.needs(MetricGroup::Address) {
92 Some(read_address(t, src, out).await)
93 } else {
94 None
95 };
96
97 for rule in &t.rules {
98 let source = match rule.metric.group() {
99 MetricGroup::Market => market.as_ref(),
100 MetricGroup::Token => token.as_ref(),
101 MetricGroup::Address => address.as_ref(),
102 };
103 let (value, status) = match source {
104 Some(Ok(values)) => {
105 let value = values
106 .iter()
107 .find(|(id, _)| *id == rule.metric)
108 .and_then(|(_, v)| *v);
109 (value, rule.eval(value))
110 }
111 Some(Err(e)) => (None, RuleStatus::Error(e.clone())),
112 None => (None, RuleStatus::Error("no source".to_string())),
113 };
114 cells.push(Cell {
115 label: rule.to_string(),
116 rule: Some(*rule),
117 value,
118 status,
119 detail: None,
120 });
121 }
122 TargetReading {
123 row: TargetSnapshot::new(t.label(), cells),
124 summary,
125 }
126}
127
128async fn read_market(
130 t: &Target,
131 contract: Option<&str>,
132 thresholds: &HealthThresholds,
133 src: Sources<'_>,
134 out: &Output,
135) -> Result<MarketSummary, String> {
136 let venue_name = t.venue.as_deref().ok_or("no venue")?;
137 let venue = MarketVenue::from_name_with_http(venue_name, src.clients.http())
138 .map_err(|e| e.to_string())?;
139 let dex = CrawlDexSource {
140 factory: src.clients,
141 out,
142 token: contract.map(str::to_string),
143 };
144 fetch_summary(&venue, &Pair::parse(&t.id), thresholds, &dex)
145 .await
146 .map_err(|e| e.to_string())
147}
148
149fn market_values(
152 t: &Target,
153 summary: &MarketSummary,
154 thresholds: &HealthThresholds,
155) -> Vec<(MetricId, Option<f64>)> {
156 let table: Vec<_> = t
157 .rules
158 .iter()
159 .filter(|r| r.metric.group() == MetricGroup::Market)
160 .map(|r| describe(r.metric, thresholds))
161 .collect();
162 let s = sample(summary, &table);
163 table.iter().map(|m| m.id).zip(s.values).collect()
164}
165
166async fn read_token(
168 t: &Target,
169 contract: Option<&str>,
170 src: Sources<'_>,
171 out: &Output,
172) -> SourceResult {
173 let chain = t.chain.as_deref().unwrap_or(scope::chains::DEFAULT_CHAIN);
174 let input = contract.unwrap_or(&t.id);
175 let a =
176 crawl::fetch_analytics_for_input(input, chain, Period::Hour24, 10, src.clients, None, out)
177 .await
178 .map_err(|e| e.to_string())?;
179 Ok(vec![
180 (MetricId::PriceUsd, Some(a.price_usd)),
181 (MetricId::PriceChange24hPct, Some(a.price_change_24h)),
182 (MetricId::Volume24h, Some(a.volume_24h)),
183 (MetricId::LiquidityUsd, Some(a.liquidity_usd)),
184 (MetricId::Holders, Some(a.total_holders as f64)),
185 ])
186}
187
188async fn read_address(t: &Target, src: Sources<'_>, out: &Output) -> SourceResult {
191 let chain = t
192 .chain
193 .clone()
194 .unwrap_or_else(|| scope::chains::DEFAULT_CHAIN.to_string());
195 let needs = |m: MetricId| t.rules.iter().any(|r| r.metric == m);
196 let mut values = Vec::new();
197 if needs(MetricId::NativeBalance) {
198 let args = AddressArgs {
199 address: t.id.clone(),
200 chain: chain.clone(),
201 format: None,
202 include_txs: false,
203 include_tokens: false,
204 limit: 1,
205 report: None,
206 dossier: false,
207 };
208 let client = src
209 .clients
210 .create_chain_client(&chain)
211 .map_err(|e| e.to_string())?;
212 let report = analyze_address(&args, client.as_ref(), out)
213 .await
214 .map_err(|e| e.to_string())?;
215 values.push((
216 MetricId::NativeBalance,
217 leading_number(&report.balance.formatted),
218 ));
219 }
220 if needs(MetricId::RiskScore) {
221 let engine = RiskEngine::with_optional_client(
222 BlockchainDataClient::from_env_opt_with_http(src.clients.http()),
223 );
224 let risk = engine
225 .assess_address(&t.id, &chain)
226 .await
227 .map_err(|e| e.to_string())?;
228 values.push((MetricId::RiskScore, Some(f64::from(risk.overall_score))));
229 }
230 Ok(values)
231}
232
233fn leading_number(text: &str) -> Option<f64> {
235 text.split_whitespace()
236 .next()?
237 .replace(',', "")
238 .parse()
239 .ok()
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use scope::chains::mocks::MockClientFactory;
246 use scope::watch::{Rule, TargetKind, TargetStatus, Watchlist};
247
248 fn ready(targets: Vec<Target>) -> Ready {
249 let contracts = vec![None; targets.len()];
250 Ready {
251 list: Watchlist {
252 targets,
253 ..Watchlist::new("t")
254 },
255 contracts,
256 }
257 }
258
259 fn quiet() -> Output {
260 Output::capture().0
261 }
262
263 #[test]
264 fn leading_numbers() {
265 assert_eq!(leading_number("1,234.5 ETH"), Some(1234.5));
266 assert_eq!(leading_number("0.1"), Some(0.1));
267 assert_eq!(leading_number("n/a"), None);
268 }
269
270 #[tokio::test]
271 async fn token_rules_read_the_analytics_once_and_judge_each_rule() {
272 let factory = MockClientFactory::new();
273 let config = scope::config::Config::default();
274 let src = Sources {
275 config: &config,
276 clients: &factory,
277 };
278 let mut t = Target::new(
279 TargetKind::Token,
280 "0x1234567890123456789012345678901234567890",
281 );
282 t.chain = Some("ethereum".into());
283 t.rules = vec![
284 Rule::parse("price_usd > 0").unwrap(),
285 Rule::parse("holders >= 100000000000").unwrap(),
286 ];
287 let tick = tick(&ready(vec![t]), src, &quiet()).await;
288 let row = &tick.snapshot.targets[0];
289 assert_eq!(row.cells.len(), 2);
290 assert_eq!(row.cells[0].label, "price_usd > 0");
291 assert!(row.cells[0].value.is_some(), "{row:?}");
292 assert_eq!(row.cells[0].status, RuleStatus::Pass, "{row:?}");
293 assert!(
294 matches!(row.cells[1].status, RuleStatus::Fail(_)),
295 "{row:?}"
296 );
297 assert_eq!(row.status, TargetStatus::Breached);
298 assert!(tick.summaries[0].is_none());
299 }
300
301 async fn summary_checks(
304 venue: &str,
305 flags: &[&str],
306 factory: &MockClientFactory,
307 ) -> (Vec<(String, String)>, bool) {
308 let mut argv = vec!["scope", "market", "summary", "USDC", "--venue", venue];
309 argv.extend(flags);
310 argv.extend(["--format", "json"]);
311 let cli = <crate::cli::Cli as clap::Parser>::try_parse_from(argv).unwrap();
312 let (o, cap) = Output::capture();
313 crate::cli::dispatch::dispatch_with(
314 cli.command,
315 &scope::config::Config::default(),
316 factory,
317 &o,
318 )
319 .await
320 .unwrap();
321 let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
322 let checks = v["checks"]
323 .as_array()
324 .unwrap()
325 .iter()
326 .map(|c| {
327 (
328 c["status"].as_str().unwrap().to_string(),
329 c["message"].as_str().unwrap().to_string(),
330 )
331 })
332 .collect();
333 (checks, v["healthy"].as_bool().unwrap())
334 }
335
336 #[tokio::test]
340 async fn the_market_health_preset_gives_the_checks_of_market_summary() {
341 let mut factory = MockClientFactory::new();
343 factory.mock_dex.search_results = vec![scope::chains::TokenSearchResult {
344 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
345 symbol: "USDC".to_string(),
346 name: "USD Coin".to_string(),
347 chain: "ethereum".to_string(),
348 price_usd: Some(1.0),
349 volume_24h: 1_000_000.0,
350 liquidity_usd: 5_000_000.0,
351 market_cap: None,
352 }];
353 if let Some(td) = factory.mock_dex.token_data.as_mut() {
354 td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
355 td.symbol = "USDC".to_string();
356 td.pairs = vec![scope::chains::DexPair {
357 dex_name: "uniswap".into(),
358 pair_address: "0xpool".into(),
359 base_token: "USDC".into(),
360 quote_token: "USDT".into(),
361 price_usd: 1.0,
362 volume_24h: 1_000_000.0,
363 liquidity_usd: 50_000_000.0,
364 liquidity_base: Some(25_000_000.0),
365 price_change_24h: 0.0,
366 buys_24h: 0,
367 sells_24h: 0,
368 buys_6h: 0,
369 sells_6h: 0,
370 buys_1h: 0,
371 sells_1h: 0,
372 pair_created_at: None,
373 url: None,
374 }];
375 }
376 let config = scope::config::Config::default();
377 let src = Sources {
378 config: &config,
379 clients: &factory,
380 };
381 for (flags, overrides) in [
382 (vec![], scope::market::HealthOverrides::default()),
383 (
384 vec!["--max-spread-pct", "0.000001", "--min-depth", "1e15"],
385 scope::market::HealthOverrides {
386 max_spread_pct: Some(0.000001),
387 min_depth: Some(1e15),
388 ..Default::default()
389 },
390 ),
391 ] {
392 let (checks, healthy) = summary_checks("eth", &flags, &factory).await;
393 assert_eq!(healthy, flags.is_empty(), "flags {flags:?}: {checks:?}");
395 let mut t = Target::new(TargetKind::Pair, "USDC");
396 t.venue = Some("eth".into());
397 t.preset = Some(Preset::MarketHealth);
398 t.health = overrides;
399 let tick = tick(&ready(vec![t]), src, &quiet()).await;
400 let row = &tick.snapshot.targets[0];
401 let cells: Vec<(String, String)> = row
402 .cells
403 .iter()
404 .map(|c| (c.status.name().to_string(), c.label.clone()))
405 .collect();
406 let expected: Vec<(String, String)> = checks
407 .iter()
408 .map(|(status, message)| {
409 let name = message.split_once(':').map_or(message.as_str(), |(n, _)| n);
410 (status.clone(), name.trim().to_string())
411 })
412 .collect();
413 assert!(!expected.is_empty());
414 assert_eq!(cells, expected, "flags {flags:?}");
415 for (cell, (_, message)) in row.cells.iter().zip(&checks) {
416 if let Some(m) = cell.status.message() {
417 assert_eq!(m, message, "the same text");
418 }
419 }
420 assert_eq!(
421 row.status == TargetStatus::Breached,
422 !healthy,
423 "flags {flags:?}"
424 );
425 }
426 }
427
428 #[tokio::test]
429 async fn address_rules_read_the_native_balance_and_the_risk_score() {
430 let factory = crate::cli::dispatch::tests::OfflineFactory::new();
433 let config = scope::config::Config::default();
434 let src = Sources {
435 config: &config,
436 clients: &factory,
437 };
438 let mut t = Target::new(
439 TargetKind::Address,
440 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
441 );
442 t.rules = vec![
443 Rule::parse("native_balance > 0.5").unwrap(),
445 Rule::parse("native_balance < 0.5").unwrap(),
446 Rule::parse("risk_score <= 10").unwrap(),
447 ];
448 let tick = tick(&ready(vec![t]), src, &quiet()).await;
449 let row = &tick.snapshot.targets[0];
450 assert_eq!(row.cells.len(), 3, "{row:?}");
451 assert_eq!(row.cells[0].value, Some(1.0), "{row:?}");
452 assert_eq!(row.cells[0].status, RuleStatus::Pass, "{row:?}");
453 assert!(
454 matches!(row.cells[1].status, RuleStatus::Fail(_)),
455 "{row:?}"
456 );
457 let risk = row.cells[2].value.expect("a risk score");
458 assert!((0.0..=10.0).contains(&risk), "{risk}");
459 assert_eq!(row.cells[2].status, RuleStatus::Pass, "{row:?}");
460 assert_eq!(row.status, TargetStatus::Breached);
461 assert!(tick.summaries[0].is_none(), "no market source was read");
462 }
463
464 #[tokio::test]
465 async fn an_address_on_its_own_chain_reads_its_balance() {
466 let factory = crate::cli::dispatch::tests::OfflineFactory::new();
467 let config = scope::config::Config::default();
468 let src = Sources {
469 config: &config,
470 clients: &factory,
471 };
472 let mut t = Target::new(
473 TargetKind::Address,
474 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
475 );
476 t.chain = Some("polygon".into());
477 t.rules = vec![Rule::parse("native_balance >= 1").unwrap()];
478 let reading = read_target(&t, None, src, &quiet()).await;
479 assert_eq!(reading.row.cells.len(), 1);
480 assert_eq!(reading.row.cells[0].value, Some(1.0));
481 assert_eq!(reading.row.status, TargetStatus::Passed);
482 }
483
484 #[tokio::test]
485 async fn a_failed_source_errors_its_cells_and_the_next_target_still_runs() {
486 let factory = MockClientFactory::new();
487 let config = scope::config::Config::default();
488 let src = Sources {
489 config: &config,
490 clients: &factory,
491 };
492 let mut bad = Target::new(TargetKind::Pair, "USDC");
493 bad.venue = Some("no-such-venue".into());
494 bad.preset = Some(Preset::MarketHealth);
495 bad.rules = vec![Rule::parse("spread_pct < 1").unwrap()];
496 let mut good = Target::new(
497 TargetKind::Token,
498 "0x1234567890123456789012345678901234567890",
499 );
500 good.rules = vec![Rule::parse("price_usd > 0").unwrap()];
501 let tick = tick(&ready(vec![bad, good]), src, &quiet()).await;
502 let row = &tick.snapshot.targets[0];
503 assert_eq!(row.cells[0].label, PRESET_ERROR_LABEL);
504 let RuleStatus::Error(text) = &row.cells[0].status else {
505 panic!("{row:?}")
506 };
507 assert!(
508 text.contains("no-such-venue"),
509 "the exact error text: {text}"
510 );
511 assert!(matches!(row.cells[1].status, RuleStatus::Error(_)));
512 assert_eq!(row.status, TargetStatus::Unknown, "never passed");
513 assert_eq!(tick.snapshot.targets[1].status, TargetStatus::Passed);
514 }
515}