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
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<String>,
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/// Parses a watchlist file's text. `default_name` names a file with no
117/// `name:`. With `venues`, a venue must be one of them.
118pub fn parse(
119    text: &str,
120    default_name: &str,
121    venues: Option<&[String]>,
122) -> Result<Watchlist, FileErrors> {
123    let raw: RawWatchlist = serde_saphyr::from_str(text).map_err(|e| {
124        FileErrors(vec![FileError {
125            line: None,
126            message: format!("not a valid watchlist: {e}"),
127        }])
128    })?;
129    let mut errors = Vec::new();
130    let mut error = |needle: Option<&str>, message: String| {
131        errors.push(FileError {
132            line: needle.and_then(|n| line_of(text, n)),
133            message,
134        })
135    };
136
137    let interval = match raw.interval.as_deref() {
138        None => Duration::from_secs(super::time::DEFAULT_INTERVAL_SECS),
139        Some(s) => match parse_duration(s) {
140            Ok(secs) => Duration::from_secs(secs),
141            Err(e) => {
142                error(Some("interval:"), format!("interval `{s}`: {e}"));
143                Duration::from_secs(super::time::DEFAULT_INTERVAL_SECS)
144            }
145        },
146    };
147    let duration = match raw.duration.as_deref() {
148        None => None,
149        Some(s) if is_until_cancelled(s) => None,
150        Some(s) => match parse_duration(s) {
151            Ok(secs) => Some(Duration::from_secs(secs)),
152            Err(e) => {
153                error(Some("duration:"), format!("duration `{s}`: {e}"));
154                None
155            }
156        },
157    };
158
159    let mut targets = Vec::new();
160    for (i, t) in raw.targets.into_iter().enumerate() {
161        let n = i + 1;
162        let id_line = format!("id: {}", t.id);
163        let at = Some(id_line.as_str());
164        let kind = match t.kind.trim().to_ascii_lowercase().as_str() {
165            "token" => TargetKind::Token,
166            "pair" => TargetKind::Pair,
167            "address" => TargetKind::Address,
168            other => {
169                error(
170                    Some(&format!("kind: {}", t.kind)),
171                    format!("target {n}: unknown kind `{other}`; use token, pair or address"),
172                );
173                continue;
174            }
175        };
176        if t.id.trim().is_empty() {
177            error(None, format!("target {n}: the id is empty"));
178        }
179        let mut target = Target::new(kind, t.id.trim());
180        target.chain = t.chain;
181        target.venue = t.venue;
182        if let (Some(venue), Some(known)) = (&target.venue, venues)
183            && !known.iter().any(|v| v.eq_ignore_ascii_case(venue))
184        {
185            error(
186                Some(&format!("venue: {venue}")),
187                format!(
188                    "target {n} ({}): unknown venue `{venue}`; the venues are {}",
189                    target.id,
190                    known.join(", ")
191                ),
192            );
193        }
194        if let Some(p) = &t.preset {
195            match Preset::from_name(p) {
196                Some(preset) if kind.accepts(crate::metrics::MetricGroup::Market) => {
197                    target.preset = Some(preset)
198                }
199                Some(_) => error(
200                    Some(&format!("preset: {p}")),
201                    format!(
202                        "target {n} ({}): the preset `{p}` needs a token or a pair, not an {}",
203                        target.id,
204                        kind.name()
205                    ),
206                ),
207                None => error(
208                    Some(&format!("preset: {p}")),
209                    format!("target {n} ({}): unknown preset `{p}`", target.id),
210                ),
211            }
212        }
213        if let Some(map) = t.health {
214            match serde_json::from_value::<HealthOverrides>(serde_json::Value::Object(map)) {
215                Ok(h) => target.health = h,
216                Err(e) => error(
217                    Some("health:"),
218                    format!("target {n} ({}): health: {e}", target.id),
219                ),
220            }
221        }
222        for text_rule in &t.rules {
223            match Rule::parse(text_rule) {
224                Ok(rule) if kind.accepts(rule.metric.group()) => target.rules.push(rule),
225                Ok(rule) => {
226                    let valid: Vec<&str> = kind.metrics().iter().map(|m| m.name()).collect();
227                    error(
228                        Some(text_rule),
229                        format!(
230                            "target {n} ({}): `{}` does not apply to a {}; its metrics are {}",
231                            target.id,
232                            rule.metric.name(),
233                            kind.name(),
234                            valid.join(", ")
235                        ),
236                    );
237                }
238                Err(e) => error(Some(text_rule), format!("target {n} ({}): {e}", target.id)),
239            }
240        }
241        if target.needs_market() && target.venue.is_none() {
242            error(
243                at,
244                format!(
245                    "target {n} ({}): market metrics need a venue (`venue: binance`)",
246                    target.id
247                ),
248            );
249        }
250        if kind == TargetKind::Pair && target.venue.is_none() && !target.needs_market() {
251            error(
252                at,
253                format!("target {n} ({}): a pair needs a venue", target.id),
254            );
255        }
256        targets.push(target);
257    }
258
259    if !errors.is_empty() {
260        return Err(FileErrors(errors));
261    }
262    Ok(Watchlist {
263        name: raw
264            .name
265            .filter(|n| !n.trim().is_empty())
266            .unwrap_or_else(|| default_name.to_string()),
267        interval,
268        duration,
269        outputs: Outputs {
270            csv: raw.outputs.csv,
271            report: raw.outputs.report,
272        },
273        targets,
274    })
275}
276
277/// The 1-based line of the first line that contains `needle`.
278fn line_of(text: &str, needle: &str) -> Option<usize> {
279    text.lines().position(|l| l.contains(needle)).map(|i| i + 1)
280}
281
282/// The file text of `w`. [`parse`] reads it back equal.
283pub fn to_text(w: &Watchlist) -> String {
284    let raw = RawWatchlist {
285        name: Some(w.name.clone()),
286        interval: Some(format_duration(w.interval.as_secs())),
287        duration: Some(
288            w.duration
289                .map_or_else(|| "forever".to_string(), |d| format_duration(d.as_secs())),
290        ),
291        outputs: RawOutputs {
292            csv: w.outputs.csv.clone(),
293            report: w.outputs.report.clone(),
294        },
295        targets: w
296            .targets
297            .iter()
298            .map(|t| RawTarget {
299                kind: t.kind.name().to_string(),
300                id: t.id.clone(),
301                chain: t.chain.clone(),
302                venue: t.venue.clone(),
303                preset: t.preset.map(|p| p.name().to_string()),
304                health: health_map(&t.health),
305                rules: t.rules.iter().map(ToString::to_string).collect(),
306            })
307            .collect(),
308    };
309    serde_saphyr::to_string(&raw).unwrap_or_default()
310}
311
312/// The overrides that are set, or `None` when none is.
313fn health_map(h: &HealthOverrides) -> Option<serde_json::Map<String, serde_json::Value>> {
314    let serde_json::Value::Object(map) = serde_json::to_value(h).ok()? else {
315        return None;
316    };
317    let set: serde_json::Map<_, _> = map.into_iter().filter(|(_, v)| !v.is_null()).collect();
318    (!set.is_empty()).then_some(set)
319}
320
321/// The file of the watchlist `name` in `dir`.
322pub fn path_in(dir: &Path, name: &str) -> PathBuf {
323    dir.join(format!("{name}.{EXTENSION}"))
324}
325
326/// Reads the watchlist at `path`. A file with no `name:` takes its stem.
327pub fn load(path: &Path, venues: Option<&[String]>) -> Result<Watchlist, FileErrors> {
328    let text = std::fs::read_to_string(path).map_err(|e| {
329        FileErrors(vec![FileError {
330            line: None,
331            message: format!("cannot read {}: {e}", path.display()),
332        }])
333    })?;
334    let stem = path
335        .file_stem()
336        .and_then(|s| s.to_str())
337        .unwrap_or("watchlist");
338    parse(&text, stem, venues)
339}
340
341/// Writes `w` to `path`, creating its folder.
342pub fn save(path: &Path, w: &Watchlist) -> std::io::Result<()> {
343    if let Some(dir) = path.parent() {
344        std::fs::create_dir_all(dir)?;
345    }
346    std::fs::write(path, to_text(w))
347}
348
349/// The watchlist names in `dir`, sorted. A missing folder has none.
350pub fn list(dir: &Path) -> Vec<String> {
351    let Ok(entries) = std::fs::read_dir(dir) else {
352        return Vec::new();
353    };
354    let mut names: Vec<String> = entries
355        .filter_map(|e| e.ok())
356        .map(|e| e.path())
357        .filter(|p| p.extension().is_some_and(|x| x == EXTENSION))
358        .filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(str::to_string))
359        .collect();
360    names.sort();
361    names
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::metrics::MetricId;
368    use crate::watch::Op;
369
370    const EXAMPLE: &str = "name: stablecoins
371interval: 30s
372duration: 1h
373outputs:
374  csv: ./watch.csv
375  report: ./watch.md
376targets:
377  - kind: pair
378    id: USDC
379    venue: binance
380    preset: market health
381    health:
382      max_spread_pct: 0.5
383  - kind: token
384    id: \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\"
385    chain: ethereum
386    venue: binance
387    rules:
388      - holders >= 1000
389      - liquidity_usd > 1000000
390      - spread_pct < 1
391  - kind: address
392    id: \"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2\"
393    rules:
394      - risk_score <= 5
395";
396
397    fn venues() -> Vec<String> {
398        vec!["binance".into(), "weex".into()]
399    }
400
401    fn errors(text: &str) -> Vec<FileError> {
402        parse(text, "w", Some(&venues())).unwrap_err().0
403    }
404
405    #[test]
406    fn the_example_loads() {
407        let w = parse(EXAMPLE, "file", Some(&venues())).unwrap();
408        assert_eq!(w.name, "stablecoins");
409        assert_eq!(w.interval, Duration::from_secs(30));
410        assert_eq!(w.duration, Some(Duration::from_secs(3600)));
411        assert_eq!(w.outputs.csv.as_deref(), Some(Path::new("./watch.csv")));
412        assert_eq!(w.targets.len(), 3);
413        let pair = &w.targets[0];
414        assert_eq!(pair.preset, Some(Preset::MarketHealth));
415        assert_eq!(pair.health.max_spread_pct, Some(0.5));
416        let token = &w.targets[1];
417        assert_eq!(token.chain.as_deref(), Some("ethereum"));
418        assert_eq!(
419            token.rules[0],
420            Rule {
421                metric: MetricId::Holders,
422                op: Op::Ge,
423                value: 1000.0
424            }
425        );
426        assert_eq!(w.targets[2].kind, TargetKind::Address);
427    }
428
429    #[test]
430    fn save_then_load_is_equal() {
431        let w = parse(EXAMPLE, "file", None).unwrap();
432        let dir = tempfile::tempdir().unwrap();
433        let path = path_in(&dir.path().join("watchlists"), "stablecoins");
434        save(&path, &w).unwrap();
435        assert_eq!(load(&path, None).unwrap(), w);
436        assert_eq!(list(&dir.path().join("watchlists")), vec!["stablecoins"]);
437    }
438
439    #[test]
440    fn until_stopped_reads_back() {
441        let w = parse("duration: forever\n", "w", None).unwrap();
442        assert_eq!(w.duration, None);
443        assert_eq!(parse(&to_text(&w), "w", None).unwrap(), w);
444        let w = parse("{}\n", "plain", None).unwrap();
445        assert_eq!((w.name.as_str(), w.duration), ("plain", None));
446        assert_eq!(w.interval, Duration::from_secs(60));
447    }
448
449    #[test]
450    fn bad_yaml_is_one_error() {
451        let e = errors("targets: [\n");
452        assert_eq!(e.len(), 1);
453        assert!(e[0].message.starts_with("not a valid watchlist"), "{e:?}");
454        let e = errors("intervall: 30s\n");
455        assert!(
456            e[0].message.contains("intervall"),
457            "unknown key named: {e:?}"
458        );
459    }
460
461    #[test]
462    fn every_problem_is_reported_with_its_line() {
463        let text = "interval: 0s
464targets:
465  - kind: pair
466    id: USDT
467  - kind: token
468    id: PEPE
469    rules:
470      - holdrs > 1
471      - risk_score < 3
472  - kind: pair
473    id: DAI
474    venue: nowhere
475    preset: market health
476  - kind: wallet
477    id: x
478";
479        let e = errors(text);
480        let find = |s: &str| {
481            e.iter()
482                .find(|x| x.message.contains(s))
483                .unwrap_or_else(|| panic!("no error with {s:?} in {e:#?}"))
484        };
485        assert_eq!(find("interval `0s`").line, Some(1));
486        assert_eq!(find("a pair needs a venue").line, Some(4));
487        let unknown = find("unknown metric `holdrs`");
488        assert_eq!(unknown.line, Some(8));
489        let kind = find("`risk_score` does not apply to a token");
490        assert_eq!(kind.line, Some(9));
491        assert!(kind.message.contains("holders"), "names the valid metrics");
492        let venue = find("unknown venue `nowhere`");
493        assert_eq!(venue.line, Some(12));
494        assert!(venue.message.contains("binance, weex"));
495        assert_eq!(find("unknown kind `wallet`").line, Some(14));
496        assert_eq!(e.len(), 6, "{e:#?}");
497        let all = FileErrors(e).to_string();
498        assert!(all.starts_with("line 1: interval"), "{all}");
499    }
500
501    #[test]
502    fn market_metrics_need_a_venue_and_the_preset_needs_a_market_kind() {
503        let e =
504            errors("targets:\n  - kind: token\n    id: PEPE\n    rules: [\"spread_pct < 1\"]\n");
505        assert!(
506            e[0].message.contains("market metrics need a venue"),
507            "{e:?}"
508        );
509        let e = errors("targets:\n  - kind: address\n    id: \"0x1\"\n    preset: market health\n");
510        assert!(e[0].message.contains("needs a token or a pair"), "{e:?}");
511    }
512
513    #[test]
514    fn a_missing_folder_lists_nothing() {
515        let dir = tempfile::tempdir().unwrap();
516        assert!(list(&dir.path().join("none")).is_empty());
517    }
518}