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