Skip to main content

scope/watch/
mod.rs

1//! # Watchlists
2//!
3//! A watchlist is a saved set of targets (tokens, pairs, addresses), each
4//! with rules on the metrics of [`crate::metrics`], plus the interval, the
5//! duration and the output files of a run. It is the "saved metric set" of
6//! #47 and the output of the TUI's monitor wizard.
7//!
8//! This module is pure: types, rule evaluation ([`rule`]), the "market
9//! health" preset ([`preset`]), snapshot comparison ([`diff`](mod@diff))
10//! and the file format ([`file`](mod@file)). The fetchers that read the
11//! metrics live in scope-cli.
12
13pub mod diff;
14pub mod file;
15pub mod preset;
16pub mod rule;
17pub mod time;
18
19pub use diff::{Event, diff};
20pub use rule::{Op, Rule, RuleStatus, TargetStatus, target_status};
21
22use crate::market::HealthOverrides;
23use crate::metrics::{MetricGroup, MetricId};
24use std::path::PathBuf;
25use std::time::Duration;
26
27/// What a target is.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum TargetKind {
31    /// A token: a symbol or a contract address.
32    Token,
33    /// A market pair on a venue.
34    Pair,
35    /// A wallet or contract address.
36    Address,
37}
38
39impl TargetKind {
40    /// Whether metrics of `group` apply to this kind of target.
41    pub fn accepts(self, group: MetricGroup) -> bool {
42        matches!(
43            (self, group),
44            (TargetKind::Token | TargetKind::Pair, MetricGroup::Market)
45                | (TargetKind::Token, MetricGroup::Token)
46                | (TargetKind::Address, MetricGroup::Address)
47        )
48    }
49
50    /// The metrics that apply to this kind, in catalog order.
51    pub fn metrics(self) -> Vec<MetricId> {
52        MetricId::ALL
53            .iter()
54            .copied()
55            .filter(|id| self.accepts(id.group()))
56            .collect()
57    }
58
59    /// The kind's name in a watchlist file.
60    pub fn name(self) -> &'static str {
61        match self {
62            TargetKind::Token => "token",
63            TargetKind::Pair => "pair",
64            TargetKind::Address => "address",
65        }
66    }
67}
68
69/// A named set of rules a target can use instead of writing them out.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum Preset {
72    /// The checks of `market summary`, with the same thresholds (P1).
73    MarketHealth,
74}
75
76impl Preset {
77    /// Every preset.
78    pub const ALL: &'static [Preset] = &[Preset::MarketHealth];
79
80    /// The preset's name in a watchlist file and on the command line.
81    pub fn name(self) -> &'static str {
82        match self {
83            Preset::MarketHealth => "market health",
84        }
85    }
86
87    /// The preset with this name (`market health` or `market-health`).
88    pub fn from_name(name: &str) -> Option<Preset> {
89        let name = name.trim().replace(['-', '_'], " ");
90        Preset::ALL
91            .iter()
92            .copied()
93            .find(|p| p.name().eq_ignore_ascii_case(&name))
94    }
95}
96
97/// One thing a watchlist watches.
98#[derive(Debug, Clone, PartialEq)]
99pub struct Target {
100    pub kind: TargetKind,
101    /// A symbol, a contract address or a wallet address.
102    pub id: String,
103    /// The chain; inferred when absent, as the CLI does.
104    pub chain: Option<String>,
105    /// The venue, for market metrics.
106    pub venue: Option<String>,
107    /// A preset, evaluated before the rules.
108    pub preset: Option<Preset>,
109    /// Health threshold overrides for the preset and the market metrics.
110    pub health: HealthOverrides,
111    pub rules: Vec<Rule>,
112}
113
114impl Target {
115    /// A target with no venue, preset or rules.
116    pub fn new(kind: TargetKind, id: impl Into<String>) -> Self {
117        Self {
118            kind,
119            id: id.into(),
120            chain: None,
121            venue: None,
122            preset: None,
123            health: HealthOverrides::default(),
124            rules: Vec::new(),
125        }
126    }
127
128    /// The name the grid and the banner show: `USDC@binance`, or the id.
129    pub fn label(&self) -> String {
130        match &self.venue {
131            Some(venue) => format!("{}@{}", self.id, venue),
132            None => self.id.clone(),
133        }
134    }
135
136    /// Whether the target reads a market summary (preset or a market rule).
137    pub fn needs_market(&self) -> bool {
138        self.preset == Some(Preset::MarketHealth)
139            || self
140                .rules
141                .iter()
142                .any(|r| r.metric.group() == MetricGroup::Market)
143    }
144
145    /// Whether any rule reads a metric of `group`.
146    pub fn needs(&self, group: MetricGroup) -> bool {
147        self.rules.iter().any(|r| r.metric.group() == group)
148    }
149}
150
151/// Where a run writes its record.
152#[derive(Debug, Clone, Default, PartialEq)]
153pub struct Outputs {
154    /// One row per rule per tick.
155    pub csv: Option<PathBuf>,
156    /// Written at the end of the run.
157    pub report: Option<PathBuf>,
158}
159
160/// A saved watchlist.
161#[derive(Debug, Clone, PartialEq)]
162pub struct Watchlist {
163    pub name: String,
164    /// Time from one tick's start to the next.
165    pub interval: Duration,
166    /// How long a run lasts; `None` runs until the user stops it.
167    pub duration: Option<Duration>,
168    pub outputs: Outputs,
169    pub targets: Vec<Target>,
170}
171
172impl Watchlist {
173    /// An empty watchlist with the default interval and duration (Q7).
174    pub fn new(name: impl Into<String>) -> Self {
175        Self {
176            name: name.into(),
177            interval: Duration::from_secs(time::DEFAULT_INTERVAL_SECS),
178            duration: Some(Duration::from_secs(time::DEFAULT_DURATION_SECS)),
179            outputs: Outputs::default(),
180            targets: Vec::new(),
181        }
182    }
183
184    /// Fetches per tick: one per source each target reads.
185    pub fn fetches_per_tick(&self) -> usize {
186        self.targets
187            .iter()
188            .map(|t| {
189                usize::from(t.needs_market())
190                    + usize::from(t.needs(MetricGroup::Token))
191                    + usize::from(t.needs(MetricGroup::Address))
192            })
193            .sum()
194    }
195
196    /// Ticks in a run with a duration: one per interval and a last one at
197    /// the end, the same rule as the repeat runner. `None` without one.
198    pub fn ticks(&self) -> Option<u64> {
199        let every = self.interval.as_millis().max(1);
200        let total = self.duration?.as_millis().div_ceil(every).saturating_add(1);
201        Some(u64::try_from(total).unwrap_or(u64::MAX))
202    }
203}
204
205/// One cell of a target's row: a preset check or a rule.
206#[derive(Debug, Clone, PartialEq)]
207pub struct Cell {
208    /// The check's name (preset) or the rule's text.
209    pub label: String,
210    /// The rule, for a rule cell.
211    pub rule: Option<Rule>,
212    /// The metric's value, when it has one.
213    pub value: Option<f64>,
214    pub status: RuleStatus,
215    /// The full text of a preset check (`Spread: 0.02% (max 3%)`), for
216    /// every status: a passing check keeps its numbers too.
217    pub detail: Option<String>,
218}
219
220/// One target's state after a tick.
221#[derive(Debug, Clone, PartialEq)]
222pub struct TargetSnapshot {
223    /// [`Target::label`].
224    pub target: String,
225    pub cells: Vec<Cell>,
226    pub status: TargetStatus,
227}
228
229impl TargetSnapshot {
230    /// A row from its cells; the status comes from the cells.
231    pub fn new(target: impl Into<String>, cells: Vec<Cell>) -> Self {
232        let statuses: Vec<RuleStatus> = cells.iter().map(|c| c.status.clone()).collect();
233        Self {
234            target: target.into(),
235            status: target_status(&statuses),
236            cells,
237        }
238    }
239}
240
241/// Every target's state after one tick, in watchlist order.
242#[derive(Debug, Clone, Default, PartialEq)]
243pub struct Snapshot {
244    pub targets: Vec<TargetSnapshot>,
245}
246
247impl Snapshot {
248    /// How many targets have a breach.
249    pub fn breaches(&self) -> usize {
250        self.targets
251            .iter()
252            .filter(|t| t.status == TargetStatus::Breached)
253            .count()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn kinds_accept_their_metric_groups() {
263        use MetricGroup::*;
264        assert!(TargetKind::Pair.accepts(Market));
265        assert!(!TargetKind::Pair.accepts(Token));
266        assert!(TargetKind::Token.accepts(Market));
267        assert!(TargetKind::Token.accepts(Token));
268        assert!(!TargetKind::Token.accepts(Address));
269        assert!(TargetKind::Address.accepts(Address));
270        assert!(!TargetKind::Address.accepts(Market));
271        assert!(TargetKind::Address.metrics().contains(&MetricId::RiskScore));
272        assert!(!TargetKind::Pair.metrics().contains(&MetricId::Holders));
273    }
274
275    #[test]
276    fn preset_names_parse_in_several_spellings() {
277        for s in [
278            "market health",
279            "Market Health",
280            "market-health",
281            "market_health",
282        ] {
283            assert_eq!(Preset::from_name(s), Some(Preset::MarketHealth), "{s}");
284        }
285        assert_eq!(Preset::from_name("peg"), None);
286    }
287
288    #[test]
289    fn label_names_the_venue() {
290        let mut t = Target::new(TargetKind::Pair, "USDC");
291        assert_eq!(t.label(), "USDC");
292        t.venue = Some("binance".into());
293        assert_eq!(t.label(), "USDC@binance");
294    }
295
296    #[test]
297    fn fetches_count_each_source_once_per_target() {
298        let mut w = Watchlist::new("w");
299        let mut pair = Target::new(TargetKind::Pair, "USDC");
300        pair.preset = Some(Preset::MarketHealth);
301        pair.rules.push(Rule::parse("spread_pct < 0.5").unwrap());
302        let mut token = Target::new(TargetKind::Token, "PEPE");
303        token.rules.push(Rule::parse("holders >= 10").unwrap());
304        token.rules.push(Rule::parse("price_usd > 0").unwrap());
305        w.targets = vec![pair, token];
306        assert_eq!(w.fetches_per_tick(), 2);
307    }
308
309    #[test]
310    fn defaults_are_market_summary_defaults_and_ticks_follow_the_runner() {
311        let w = Watchlist::new("w");
312        assert_eq!(w.interval, Duration::from_secs(60));
313        assert_eq!(w.duration, Some(Duration::from_secs(3600)));
314        assert_eq!(w.ticks(), Some(61));
315        let w = Watchlist {
316            duration: None,
317            ..Watchlist::new("w")
318        };
319        assert_eq!(w.ticks(), None);
320    }
321
322    #[test]
323    fn snapshot_status_comes_from_the_cells() {
324        let cell = |status| Cell {
325            label: "x".into(),
326            rule: None,
327            value: None,
328            status,
329            detail: None,
330        };
331        let row = TargetSnapshot::new(
332            "A",
333            vec![cell(RuleStatus::Pass), cell(RuleStatus::Fail("f".into()))],
334        );
335        assert_eq!(row.status, TargetStatus::Breached);
336        let s = Snapshot {
337            targets: vec![row, TargetSnapshot::new("B", vec![cell(RuleStatus::Pass)])],
338        };
339        assert_eq!(s.breaches(), 1);
340    }
341}