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<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
116pub 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
277fn line_of(text: &str, needle: &str) -> Option<usize> {
279 text.lines().position(|l| l.contains(needle)).map(|i| i + 1)
280}
281
282pub 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
312fn 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
321pub fn path_in(dir: &Path, name: &str) -> PathBuf {
323 dir.join(format!("{name}.{EXTENSION}"))
324}
325
326pub 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
341pub 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
349pub 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}