1use 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
36pub const EXTENSION: &str = "yaml";
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct FileError {
42 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#[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#[derive(Debug, serde::Deserialize, serde::Serialize)]
118#[serde(untagged)]
119enum RawVenues {
120 One(String),
121 Many(Vec<String>),
122}
123
124impl RawVenues {
125 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
143pub 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
319fn 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
336fn line_of(text: &str, needle: &str) -> Option<usize> {
338 text.lines().position(|l| l.contains(needle)).map(|i| i + 1)
339}
340
341pub 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
371fn 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
380pub fn path_in(dir: &Path, name: &str) -> PathBuf {
382 dir.join(format!("{name}.{EXTENSION}"))
383}
384
385pub 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
400pub 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
408pub 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 #[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 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}