Skip to main content

scope_cli/cli/watch/
fetch.rs

1//! One tick: read every target's metrics and judge its rules.
2//!
3//! Each source is read once per target per tick, however many rules use
4//! it. A failed read makes that source's cells `Error` with the exact
5//! error text; other sources and other targets go on.
6
7use 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
18/// The cell a preset shows when its summary could not be read.
19pub const PRESET_ERROR_LABEL: &str = "market health";
20
21/// One tick's results.
22#[derive(Debug, Clone, Default)]
23pub struct Tick {
24    pub snapshot: Snapshot,
25    /// For each target, the market summary it read, for the report and
26    /// the detail pane.
27    pub summaries: Vec<Option<MarketSummary>>,
28}
29
30/// One target's results.
31#[derive(Debug, Clone)]
32pub struct TargetReading {
33    pub row: TargetSnapshot,
34    pub summary: Option<MarketSummary>,
35}
36
37/// Reads and judges every target of `ready`, in order.
38pub 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
48/// What one source gave: values by metric, or the error text.
49type SourceResult = Result<Vec<(MetricId, Option<f64>)>, String>;
50
51/// Reads and judges one target.
52pub 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
128/// The market summary of a target, the same fetch as `market summary`.
129async 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
149/// Every market value of the target's rules, read from `summary` with the
150/// target's thresholds (the same values the repeating view charts).
151fn 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
166/// Token analytics, through the crawl pipeline.
167async 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
188/// The address values the target's rules read: its native balance and its
189/// risk score, each fetched only when a rule needs it.
190async 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
233/// The number at the start of a formatted amount (`1,234.5 ETH` is 1234.5).
234fn 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    /// The checks of `market summary <pair> --venue <venue> --format json`
302    /// with `flags`, as (status, message), and its health.
303    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    /// #47 acceptance: the monitor wizard's market health preset gives the
337    /// same checks, in the same order and with the same status and health,
338    /// as `market summary` for the same venue and thresholds.
339    #[tokio::test]
340    async fn the_market_health_preset_gives_the_checks_of_market_summary() {
341        // One USDC on the DEX venue's chain, the same for both paths.
342        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            // The strict thresholds make failures, so both statuses are compared.
394            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        // Offline: with an Etherscan key configured on the test machine the
431        // risk engine would otherwise call out through the transport.
432        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            // The mock's balance is formatted "1.0".
444            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}