Skip to main content

scope/watch/
file.rs

1//! The watchlist file: `<scope home>/watchlists/<name>.yaml`.
2//!
3//! ```yaml
4//! name: stablecoins
5//! interval: 30s
6//! duration: 1h          # optional; absent or `forever` = until stopped
7//! outputs:
8//!   csv: ./watch.csv
9//!   report: ./watch.md
10//! targets:
11//!   - kind: pair
12//!     id: USDC
13//!     venue: binance    # or a list: [binance, weex]
14//!     preset: market health
15//!     health:
16//!       max_spread_pct: 0.5
17//!   - kind: token
18//!     id: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
19//!     chain: ethereum
20//!     rules:
21//!       - holders >= 1000
22//!       - liquidity_usd > 1000000
23//! ```
24//!
25//! [`parse`] and [`to_text`] are the only functions that touch the YAML
26//! crate (#47, N7). [`parse`] reports every error with its line, not only
27//! the first.
28
29use super::time::{format_duration, is_until_cancelled, parse_duration};
30use super::{Outputs, Preset, Rule, Target, TargetKind, Watchlist};
31use crate::market::HealthOverrides;
32use std::fmt;
33use std::path::{Path, PathBuf};
34use std::time::Duration;
35
36/// The file extension of a watchlist.
37pub const EXTENSION: &str = "yaml";
38
39/// One problem in a watchlist file.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct FileError {
42    /// 1-based line, when the problem has one.
43    pub line: Option<usize>,
44    pub message: String,
45}
46
47impl fmt::Display for FileError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self.line {
50            Some(line) => write!(f, "line {line}: {}", self.message),
51            None => f.write_str(&self.message),
52        }
53    }
54}
55
56/// Every problem in a watchlist file, one per line of the message.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct FileErrors(pub Vec<FileError>);
59
60impl fmt::Display for FileErrors {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        let lines: Vec<String> = self.0.iter().map(ToString::to_string).collect();
63        f.write_str(&lines.join("\n"))
64    }
65}
66
67impl std::error::Error for FileErrors {}
68
69#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
70#[serde(deny_unknown_fields)]
71struct RawWatchlist {
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    name: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    interval: Option<String>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    duration: Option<String>,
78    #[serde(default, skip_serializing_if = "RawOutputs::is_empty")]
79    outputs: RawOutputs,
80    #[serde(default)]
81    targets: Vec<RawTarget>,
82}
83
84#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
85#[serde(deny_unknown_fields)]
86struct RawOutputs {
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    csv: Option<PathBuf>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    report: Option<PathBuf>,
91}
92
93impl RawOutputs {
94    fn is_empty(&self) -> bool {
95        self.csv.is_none() && self.report.is_none()
96    }
97}
98
99#[derive(Debug, serde::Deserialize, serde::Serialize)]
100#[serde(deny_unknown_fields)]
101struct RawTarget {
102    kind: String,
103    id: String,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    chain: Option<String>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    venue: Option<RawVenues>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    preset: Option<String>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    health: Option<serde_json::Map<String, serde_json::Value>>,
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    rules: Vec<String>,
114}
115
116/// `venue:` in a file: one name (the format before venue lists) or a list.
117#[derive(Debug, serde::Deserialize, serde::Serialize)]
118#[serde(untagged)]
119enum RawVenues {
120    One(String),
121    Many(Vec<String>),
122}
123
124impl RawVenues {
125    /// One venue as a name, so a single-venue file is written as before
126    /// and an older scope still reads it; several as a list.
127    fn from_list(venues: &[String]) -> Option<Self> {
128        match venues {
129            [] => None,
130            [one] => Some(RawVenues::One(one.clone())),
131            many => Some(RawVenues::Many(many.to_vec())),
132        }
133    }
134
135    fn into_list(self) -> Vec<String> {
136        match self {
137            RawVenues::One(one) => vec![one],
138            RawVenues::Many(many) => many,
139        }
140    }
141}
142
143/// Parses a watchlist file's text. `default_name` names a file with no
144/// `name:`. With `venues`, a venue must be one of them.
145pub fn parse(
146    text: &str,
147    default_name: &str,
148    venues: Option<&[String]>,
149) -> Result<Watchlist, FileErrors> {
150    let raw: RawWatchlist = serde_saphyr::from_str(text).map_err(|e| {
151        FileErrors(vec![FileError {
152            line: None,
153            message: format!("not a valid watchlist: {e}"),
154        }])
155    })?;
156    let mut errors = Vec::new();
157    let mut error = |needle: Option<&str>, message: String| {
158        errors.push(FileError {
159            line: needle.and_then(|n| line_of(text, n)),
160            message,
161        })
162    };
163
164    let interval = match raw.interval.as_deref() {
165        None => Duration::from_secs(super::time::DEFAULT_INTERVAL_SECS),
166        Some(s) => match parse_duration(s) {
167            Ok(secs) => Duration::from_secs(secs),
168            Err(e) => {
169                error(Some("interval:"), format!("interval `{s}`: {e}"));
170                Duration::from_secs(super::time::DEFAULT_INTERVAL_SECS)
171            }
172        },
173    };
174    let duration = match raw.duration.as_deref() {
175        None => None,
176        Some(s) if is_until_cancelled(s) => None,
177        Some(s) => match parse_duration(s) {
178            Ok(secs) => Some(Duration::from_secs(secs)),
179            Err(e) => {
180                error(Some("duration:"), format!("duration `{s}`: {e}"));
181                None
182            }
183        },
184    };
185
186    let mut targets = Vec::new();
187    for (i, t) in raw.targets.into_iter().enumerate() {
188        let n = i + 1;
189        let id_line = format!("id: {}", t.id);
190        let at = Some(id_line.as_str());
191        let kind = match t.kind.trim().to_ascii_lowercase().as_str() {
192            "token" => TargetKind::Token,
193            "pair" => TargetKind::Pair,
194            "address" => TargetKind::Address,
195            other => {
196                error(
197                    Some(&format!("kind: {}", t.kind)),
198                    format!("target {n}: unknown kind `{other}`; use token, pair or address"),
199                );
200                continue;
201            }
202        };
203        if t.id.trim().is_empty() {
204            error(None, format!("target {n}: the id is empty"));
205        }
206        let mut target = Target::new(kind, t.id.trim());
207        target.chain = t.chain;
208        for venue in t.venue.map(RawVenues::into_list).unwrap_or_default() {
209            let venue = venue.trim().to_string();
210            let at = venue_line(text, &venue);
211            let at = at.as_deref();
212            if target.venues.iter().any(|v| v.eq_ignore_ascii_case(&venue)) {
213                error(
214                    at,
215                    format!(
216                        "target {n} ({}): venue `{venue}` is listed twice",
217                        target.id
218                    ),
219                );
220                continue;
221            }
222            if let Some(known) = venues
223                && !known.iter().any(|v| v.eq_ignore_ascii_case(&venue))
224            {
225                error(
226                    at,
227                    format!(
228                        "target {n} ({}): unknown venue `{venue}`; the venues are {}",
229                        target.id,
230                        known.join(", ")
231                    ),
232                );
233            }
234            target.venues.push(venue);
235        }
236        if let Some(p) = &t.preset {
237            match Preset::from_name(p) {
238                Some(preset) if kind.accepts(crate::metrics::MetricGroup::Market) => {
239                    target.preset = Some(preset)
240                }
241                Some(_) => error(
242                    Some(&format!("preset: {p}")),
243                    format!(
244                        "target {n} ({}): the preset `{p}` needs a token or a pair, not an {}",
245                        target.id,
246                        kind.name()
247                    ),
248                ),
249                None => error(
250                    Some(&format!("preset: {p}")),
251                    format!("target {n} ({}): unknown preset `{p}`", target.id),
252                ),
253            }
254        }
255        if let Some(map) = t.health {
256            match serde_json::from_value::<HealthOverrides>(serde_json::Value::Object(map)) {
257                Ok(h) => target.health = h,
258                Err(e) => error(
259                    Some("health:"),
260                    format!("target {n} ({}): health: {e}", target.id),
261                ),
262            }
263        }
264        for text_rule in &t.rules {
265            match Rule::parse(text_rule) {
266                Ok(rule) if kind.accepts(rule.metric.group()) => target.rules.push(rule),
267                Ok(rule) => {
268                    let valid: Vec<&str> = kind.metrics().iter().map(|m| m.name()).collect();
269                    error(
270                        Some(text_rule),
271                        format!(
272                            "target {n} ({}): `{}` does not apply to a {}; its metrics are {}",
273                            target.id,
274                            rule.metric.name(),
275                            kind.name(),
276                            valid.join(", ")
277                        ),
278                    );
279                }
280                Err(e) => error(Some(text_rule), format!("target {n} ({}): {e}", target.id)),
281            }
282        }
283        if target.needs_market() && target.venues.is_empty() {
284            error(
285                at,
286                format!(
287                    "target {n} ({}): market metrics need a venue (`venue: binance`)",
288                    target.id
289                ),
290            );
291        }
292        if kind == TargetKind::Pair && target.venues.is_empty() && !target.needs_market() {
293            error(
294                at,
295                format!("target {n} ({}): a pair needs a venue", target.id),
296            );
297        }
298        targets.push(target);
299    }
300
301    if !errors.is_empty() {
302        return Err(FileErrors(errors));
303    }
304    Ok(Watchlist {
305        name: raw
306            .name
307            .filter(|n| !n.trim().is_empty())
308            .unwrap_or_else(|| default_name.to_string()),
309        interval,
310        duration,
311        outputs: Outputs {
312            csv: raw.outputs.csv,
313            report: raw.outputs.report,
314        },
315        targets,
316    })
317}
318
319/// The first line that names `venue`: `venue: binance`, a flow list
320/// `venue: [binance, weex]`, or an item `- weex` of a block list.
321fn venue_line(text: &str, venue: &str) -> Option<String> {
322    text.lines()
323        .find(|l| {
324            let l = l.trim();
325            let named = |s: &str| {
326                s.trim_matches(|c: char| matches!(c, '[' | ']' | '"' | '\'') || c.is_whitespace())
327                    == venue
328            };
329            l.strip_prefix("venue:")
330                .is_some_and(|rest| rest.split(',').any(named))
331                || l.strip_prefix('-').is_some_and(named)
332        })
333        .map(str::to_string)
334}
335
336/// The 1-based line of the first line that contains `needle`.
337fn line_of(text: &str, needle: &str) -> Option<usize> {
338    text.lines().position(|l| l.contains(needle)).map(|i| i + 1)
339}
340
341/// The file text of `w`. [`parse`] reads it back equal.
342pub fn to_text(w: &Watchlist) -> String {
343    let raw = RawWatchlist {
344        name: Some(w.name.clone()),
345        interval: Some(format_duration(w.interval.as_secs())),
346        duration: Some(
347            w.duration
348                .map_or_else(|| "forever".to_string(), |d| format_duration(d.as_secs())),
349        ),
350        outputs: RawOutputs {
351            csv: w.outputs.csv.clone(),
352            report: w.outputs.report.clone(),
353        },
354        targets: w
355            .targets
356            .iter()
357            .map(|t| RawTarget {
358                kind: t.kind.name().to_string(),
359                id: t.id.clone(),
360                chain: t.chain.clone(),
361                venue: RawVenues::from_list(&t.venues),
362                preset: t.preset.map(|p| p.name().to_string()),
363                health: health_map(&t.health),
364                rules: t.rules.iter().map(ToString::to_string).collect(),
365            })
366            .collect(),
367    };
368    serde_saphyr::to_string(&raw).unwrap_or_default()
369}
370
371/// The overrides that are set, or `None` when none is.
372fn health_map(h: &HealthOverrides) -> Option<serde_json::Map<String, serde_json::Value>> {
373    let serde_json::Value::Object(map) = serde_json::to_value(h).ok()? else {
374        return None;
375    };
376    let set: serde_json::Map<_, _> = map.into_iter().filter(|(_, v)| !v.is_null()).collect();
377    (!set.is_empty()).then_some(set)
378}
379
380/// The file of the watchlist `name` in `dir`.
381pub fn path_in(dir: &Path, name: &str) -> PathBuf {
382    dir.join(format!("{name}.{EXTENSION}"))
383}
384
385/// Reads the watchlist at `path`. A file with no `name:` takes its stem.
386pub fn load(path: &Path, venues: Option<&[String]>) -> Result<Watchlist, FileErrors> {
387    let text = std::fs::read_to_string(path).map_err(|e| {
388        FileErrors(vec![FileError {
389            line: None,
390            message: format!("cannot read {}: {e}", path.display()),
391        }])
392    })?;
393    let stem = path
394        .file_stem()
395        .and_then(|s| s.to_str())
396        .unwrap_or("watchlist");
397    parse(&text, stem, venues)
398}
399
400/// Writes `w` to `path`, creating its folder.
401pub fn save(path: &Path, w: &Watchlist) -> std::io::Result<()> {
402    if let Some(dir) = path.parent() {
403        std::fs::create_dir_all(dir)?;
404    }
405    std::fs::write(path, to_text(w))
406}
407
408/// The watchlist names in `dir`, sorted. A missing folder has none.
409pub fn list(dir: &Path) -> Vec<String> {
410    let Ok(entries) = std::fs::read_dir(dir) else {
411        return Vec::new();
412    };
413    let mut names: Vec<String> = entries
414        .filter_map(|e| e.ok())
415        .map(|e| e.path())
416        .filter(|p| p.extension().is_some_and(|x| x == EXTENSION))
417        .filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(str::to_string))
418        .collect();
419    names.sort();
420    names
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::metrics::MetricId;
427    use crate::watch::Op;
428
429    const EXAMPLE: &str = "name: stablecoins
430interval: 30s
431duration: 1h
432outputs:
433  csv: ./watch.csv
434  report: ./watch.md
435targets:
436  - kind: pair
437    id: USDC
438    venue: binance
439    preset: market health
440    health:
441      max_spread_pct: 0.5
442  - kind: token
443    id: \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\"
444    chain: ethereum
445    venue: binance
446    rules:
447      - holders >= 1000
448      - liquidity_usd > 1000000
449      - spread_pct < 1
450  - kind: address
451    id: \"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2\"
452    rules:
453      - risk_score <= 5
454";
455
456    fn venues() -> Vec<String> {
457        vec!["binance".into(), "weex".into()]
458    }
459
460    fn errors(text: &str) -> Vec<FileError> {
461        parse(text, "w", Some(&venues())).unwrap_err().0
462    }
463
464    #[test]
465    fn the_example_loads() {
466        let w = parse(EXAMPLE, "file", Some(&venues())).unwrap();
467        assert_eq!(w.name, "stablecoins");
468        assert_eq!(w.interval, Duration::from_secs(30));
469        assert_eq!(w.duration, Some(Duration::from_secs(3600)));
470        assert_eq!(w.outputs.csv.as_deref(), Some(Path::new("./watch.csv")));
471        assert_eq!(w.targets.len(), 3);
472        let pair = &w.targets[0];
473        assert_eq!(pair.preset, Some(Preset::MarketHealth));
474        assert_eq!(pair.health.max_spread_pct, Some(0.5));
475        let token = &w.targets[1];
476        assert_eq!(token.chain.as_deref(), Some("ethereum"));
477        assert_eq!(
478            token.rules[0],
479            Rule {
480                metric: MetricId::Holders,
481                op: Op::Ge,
482                value: 1000.0
483            }
484        );
485        assert_eq!(w.targets[2].kind, TargetKind::Address);
486    }
487
488    #[test]
489    fn save_then_load_is_equal() {
490        let w = parse(EXAMPLE, "file", None).unwrap();
491        let dir = tempfile::tempdir().unwrap();
492        let path = path_in(&dir.path().join("watchlists"), "stablecoins");
493        save(&path, &w).unwrap();
494        assert_eq!(load(&path, None).unwrap(), w);
495        assert_eq!(list(&dir.path().join("watchlists")), vec!["stablecoins"]);
496    }
497
498    #[test]
499    fn until_stopped_reads_back() {
500        let w = parse("duration: forever\n", "w", None).unwrap();
501        assert_eq!(w.duration, None);
502        assert_eq!(parse(&to_text(&w), "w", None).unwrap(), w);
503        let w = parse("{}\n", "plain", None).unwrap();
504        assert_eq!((w.name.as_str(), w.duration), ("plain", None));
505        assert_eq!(w.interval, Duration::from_secs(60));
506    }
507
508    #[test]
509    fn bad_yaml_is_one_error() {
510        let e = errors("targets: [\n");
511        assert_eq!(e.len(), 1);
512        assert!(e[0].message.starts_with("not a valid watchlist"), "{e:?}");
513        let e = errors("intervall: 30s\n");
514        assert!(
515            e[0].message.contains("intervall"),
516            "unknown key named: {e:?}"
517        );
518    }
519
520    #[test]
521    fn every_problem_is_reported_with_its_line() {
522        let text = "interval: 0s
523targets:
524  - kind: pair
525    id: USDT
526  - kind: token
527    id: PEPE
528    rules:
529      - holdrs > 1
530      - risk_score < 3
531  - kind: pair
532    id: DAI
533    venue: nowhere
534    preset: market health
535  - kind: wallet
536    id: x
537";
538        let e = errors(text);
539        let find = |s: &str| {
540            e.iter()
541                .find(|x| x.message.contains(s))
542                .unwrap_or_else(|| panic!("no error with {s:?} in {e:#?}"))
543        };
544        assert_eq!(find("interval `0s`").line, Some(1));
545        assert_eq!(find("a pair needs a venue").line, Some(4));
546        let unknown = find("unknown metric `holdrs`");
547        assert_eq!(unknown.line, Some(8));
548        let kind = find("`risk_score` does not apply to a token");
549        assert_eq!(kind.line, Some(9));
550        assert!(kind.message.contains("holders"), "names the valid metrics");
551        let venue = find("unknown venue `nowhere`");
552        assert_eq!(venue.line, Some(12));
553        assert!(venue.message.contains("binance, weex"));
554        assert_eq!(find("unknown kind `wallet`").line, Some(14));
555        assert_eq!(e.len(), 6, "{e:#?}");
556        let all = FileErrors(e).to_string();
557        assert!(all.starts_with("line 1: interval"), "{all}");
558    }
559
560    #[test]
561    fn market_metrics_need_a_venue_and_the_preset_needs_a_market_kind() {
562        let e =
563            errors("targets:\n  - kind: token\n    id: PEPE\n    rules: [\"spread_pct < 1\"]\n");
564        assert!(
565            e[0].message.contains("market metrics need a venue"),
566            "{e:?}"
567        );
568        let e = errors("targets:\n  - kind: address\n    id: \"0x1\"\n    preset: market health\n");
569        assert!(e[0].message.contains("needs a token or a pair"), "{e:?}");
570    }
571
572    // Files written before venue lists have one `venue:` name. They must
573    // load, and a save must not rewrite them, so an older scope can still
574    // read them.
575    #[test]
576    fn a_single_venue_file_loads_and_saves_unchanged() {
577        let text = "name: w\ninterval: 1m\nduration: 1h\ntargets:\n- kind: pair\n  id: USDC\n  venue: binance\n  preset: market health\n";
578        let w = parse(text, "w", Some(&venues())).unwrap();
579        assert_eq!(w.targets[0].venues, vec!["binance"]);
580        assert_eq!(to_text(&w), text);
581    }
582
583    #[test]
584    fn a_venue_list_loads_in_order_in_both_list_styles() {
585        let flow = "targets:\n  - kind: pair\n    id: USDN\n    venue: [weex, binance]\n    preset: market health\n";
586        let block = "targets:\n  - kind: pair\n    id: USDN\n    venue:\n      - weex\n      - binance\n    preset: market health\n";
587        for text in [flow, block] {
588            let w = parse(text, "w", Some(&venues())).unwrap();
589            assert_eq!(w.targets[0].venues, vec!["weex", "binance"], "{text}");
590            assert_eq!(parse(&to_text(&w), "w", None).unwrap(), w);
591        }
592    }
593
594    #[test]
595    fn venue_list_errors_have_their_lines() {
596        let e = errors(
597            "targets:\n  - kind: pair\n    id: USDN\n    venue: [weex, nowhere, weex]\n    preset: market health\n",
598        );
599        assert_eq!(e.len(), 2, "{e:#?}");
600        assert!(e[0].message.contains("unknown venue `nowhere`"), "{e:?}");
601        assert!(e[1].message.contains("`weex` is listed twice"), "{e:?}");
602        assert!(e.iter().all(|x| x.line == Some(4)), "{e:?}");
603        let e = errors(
604            "targets:\n  - kind: pair\n    id: USDN\n    venue:\n      - weex\n      - nowhere\n",
605        );
606        assert_eq!(e[0].line, Some(6), "{e:?}");
607        // An empty list is no venue.
608        let e = errors(
609            "targets:\n  - kind: pair\n    id: USDN\n    venue: []\n    preset: market health\n",
610        );
611        assert!(
612            e[0].message.contains("market metrics need a venue"),
613            "{e:?}"
614        );
615    }
616
617    #[test]
618    fn a_missing_folder_lists_nothing() {
619        let dir = tempfile::tempdir().unwrap();
620        assert!(list(&dir.path().join("none")).is_empty());
621    }
622}