1use crate::cli::monitor::MonitorArgs;
5use scope::config::Config;
6use scope::metrics::MetricId;
7use scope::watch::time::{is_until_cancelled, parse_duration};
8use scope::watch::{Op, Preset, Rule, Target, TargetKind, Watchlist};
9use std::time::Duration;
10
11pub const ALERTS_ISSUE: &str = "#122";
13
14fn ignored(flag: &str) -> String {
16 format!(
17 "{flag} has no effect: the watchlist grid has no layouts. Use `--dashboard` for the old dashboard (deprecated)."
18 )
19}
20
21pub fn watchlist_from_args(
28 args: &MonitorArgs,
29 config: &Config,
30) -> Result<(Watchlist, Vec<String>), String> {
31 let w = &args.watch;
32 let mut notes = Vec::new();
33 let mut list = Watchlist::new(
34 w.save
35 .clone()
36 .unwrap_or_else(|| format!("monitor-{}", args.token.to_lowercase())),
37 );
38 if let Some(every) = &w.every {
39 list.interval =
40 Duration::from_secs(parse_duration(every).map_err(|e| format!("--every: {e}"))?);
41 } else if let Some(secs) = args.refresh {
42 if secs == 0 {
43 return Err("--refresh must be at least 1 second".to_string());
44 }
45 list.interval = Duration::from_secs(secs);
46 }
47 if let Some(d) = &w.duration {
48 list.duration = if is_until_cancelled(d) {
49 None
50 } else {
51 Some(Duration::from_secs(
52 parse_duration(d).map_err(|e| format!("--duration: {e}"))?,
53 ))
54 };
55 }
56 list.outputs.csv = w.csv.clone().or_else(|| args.export.clone());
57 list.outputs.report = w.report.clone();
58
59 let preset = match w.preset.as_deref() {
60 None => args.venue.is_some().then_some(Preset::MarketHealth),
61 Some(p) if p.eq_ignore_ascii_case("none") => None,
62 Some(p) => Some(
63 Preset::from_name(p)
64 .ok_or_else(|| format!("unknown preset `{p}`; use market-health or none"))?,
65 ),
66 };
67 let mut target = match &args.venue {
68 Some(venue) => {
69 let mut t = Target::new(
70 TargetKind::Pair,
71 args.pair.clone().unwrap_or_else(|| args.token.clone()),
72 );
73 t.venue = Some(venue.clone());
74 t
75 }
76 None => {
77 if preset.is_some() {
78 return Err("the market health checks need a venue (`--venue binance`)".to_string());
79 }
80 let mut t = Target::new(
81 TargetKind::Token,
82 args.token_contract
83 .clone()
84 .unwrap_or_else(|| args.token.clone()),
85 );
86 t.rules = vec![
88 Rule {
89 metric: MetricId::PriceUsd,
90 op: Op::Gt,
91 value: 0.0,
92 },
93 Rule {
94 metric: MetricId::Volume24h,
95 op: Op::Ge,
96 value: 0.0,
97 },
98 ];
99 t
100 }
101 };
102 if args.chain != scope::chains::DEFAULT_CHAIN {
103 target.chain = Some(args.chain.clone());
104 }
105 target.preset = preset;
106 target.health = w.health.overrides();
107
108 let alerts = &config.monitor.alerts;
111 let price = match target.kind {
112 TargetKind::Pair => MetricId::Mid,
113 _ => MetricId::PriceUsd,
114 };
115 if let Some(min) = alerts.price_min {
116 target.rules.push(Rule {
117 metric: price,
118 op: Op::Ge,
119 value: min,
120 });
121 }
122 if let Some(max) = alerts.price_max {
123 target.rules.push(Rule {
124 metric: price,
125 op: Op::Le,
126 value: max,
127 });
128 }
129 if alerts.whale_min_usd.is_some() || alerts.volume_spike_threshold_pct.is_some() {
130 notes.push(format!(
131 "The whale and volume-spike alerts of config `monitor.alerts` are not checked: they have no watchlist metric yet ({ALERTS_ISSUE})."
132 ));
133 }
134 for (set, flag) in [
135 (args.layout.is_some(), "--layout"),
136 (args.scale.is_some(), "--scale"),
137 (args.color_scheme.is_some(), "--color-scheme"),
138 ] {
139 if set {
140 notes.push(ignored(flag));
141 }
142 }
143 if args.export.is_some() && w.csv.is_none() {
144 notes.push("--export is now --csv: it writes one row per check per run.".to_string());
145 }
146 list.targets.push(target);
147 Ok((list, notes))
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::cli::Cli;
154 use clap::Parser;
155
156 fn from(argv: &str) -> Result<(Watchlist, Vec<String>), String> {
157 from_with(argv, &Config::default())
158 }
159
160 fn from_with(argv: &str, config: &Config) -> Result<(Watchlist, Vec<String>), String> {
161 let words: Vec<&str> = std::iter::once("scope")
162 .chain(argv.split_whitespace())
163 .collect();
164 let cli = Cli::try_parse_from(words).map_err(|e| e.to_string())?;
165 let crate::cli::Commands::Monitor(args) = cli.command else {
166 panic!("not monitor")
167 };
168 watchlist_from_args(&args, config)
169 }
170
171 #[test]
172 fn the_trigger_case_is_usdn_on_weex_with_the_health_checks_every_30s_for_1h() {
173 let (w, notes) = from("monitor USDN --venue weex --every 30s --duration 1h").unwrap();
174 assert!(notes.is_empty(), "{notes:?}");
175 assert_eq!(w.interval, Duration::from_secs(30));
176 assert_eq!(w.duration, Some(Duration::from_secs(3600)));
177 let t = &w.targets[0];
178 assert_eq!((t.kind, t.id.as_str()), (TargetKind::Pair, "USDN"));
179 assert_eq!(t.venue.as_deref(), Some("weex"));
180 assert_eq!(t.preset, Some(Preset::MarketHealth));
181 }
182
183 #[test]
184 fn refresh_and_every_give_the_same_interval_and_defaults_are_60s_1h() {
185 let a = from("monitor USDC --venue binance --refresh 30").unwrap().0;
186 let b = from("monitor USDC --venue binance --every 30s").unwrap().0;
187 assert_eq!(a.interval, b.interval);
188 let d = from("monitor USDC --venue binance").unwrap().0;
189 assert_eq!(d.interval, Duration::from_secs(60));
190 assert_eq!(d.duration, Some(Duration::from_secs(3600)));
191 assert!(
192 from("monitor USDC --refresh 5 --every 5s").is_err(),
193 "they conflict"
194 );
195 let f = from("monitor USDC --venue binance --duration forever")
196 .unwrap()
197 .0;
198 assert_eq!(f.duration, None);
199 }
200
201 #[test]
202 fn pair_csv_report_health_preset_save_map() {
203 let (w, notes) = from(
204 "monitor DAI --venue binance --pair DAI_USDT --csv a.csv --report a.md --max-spread-pct 0.5 --save stables",
205 )
206 .unwrap();
207 assert!(notes.is_empty(), "{notes:?}");
208 assert_eq!(w.name, "stables");
209 assert_eq!(
210 w.outputs.csv.as_deref(),
211 Some(std::path::Path::new("a.csv"))
212 );
213 assert_eq!(
214 w.outputs.report.as_deref(),
215 Some(std::path::Path::new("a.md"))
216 );
217 let t = &w.targets[0];
218 assert_eq!(t.id, "DAI_USDT");
219 assert_eq!(t.health.max_spread_pct, Some(0.5));
220 let none = from("monitor DAI --venue binance --preset none").unwrap().0;
221 assert_eq!(none.targets[0].preset, None);
222 assert!(
223 from("monitor DAI --venue binance --preset peg")
224 .unwrap_err()
225 .contains("unknown preset")
226 );
227 }
228
229 #[test]
230 fn without_a_venue_the_token_gets_price_and_volume_and_no_preset() {
231 let (w, _) = from("monitor PEPE --chain solana").unwrap();
232 let t = &w.targets[0];
233 assert_eq!(t.kind, TargetKind::Token);
234 assert_eq!(t.chain.as_deref(), Some("solana"));
235 let names: Vec<String> = t.rules.iter().map(ToString::to_string).collect();
236 assert_eq!(names, ["price_usd > 0", "volume_24h >= 0"]);
237 assert!(
238 from("monitor PEPE --preset market-health")
239 .unwrap_err()
240 .contains("need a venue")
241 );
242 }
243
244 #[test]
245 fn dashboard_flags_give_one_note_each_and_export_is_the_csv() {
246 let (w, notes) = from(
247 "monitor USDC --venue binance --layout chart-focus --scale log --color-scheme monochrome --export x.csv",
248 )
249 .unwrap();
250 assert_eq!(notes.len(), 4, "{notes:?}");
251 assert!(notes[0].starts_with("--layout has no effect"));
252 assert!(notes[3].contains("--export is now --csv"));
253 assert_eq!(
254 w.outputs.csv.as_deref(),
255 Some(std::path::Path::new("x.csv"))
256 );
257 }
258
259 #[test]
260 fn price_alerts_become_rules_and_whale_alerts_name_the_issue() {
261 let mut config = Config::default();
262 config.monitor.alerts.price_min = Some(0.99);
263 config.monitor.alerts.price_max = Some(1.01);
264 config.monitor.alerts.whale_min_usd = Some(100_000.0);
265 let (w, notes) = from_with("monitor USDC --venue binance", &config).unwrap();
266 let rules: Vec<String> = w.targets[0].rules.iter().map(ToString::to_string).collect();
267 assert_eq!(rules, ["mid_price >= 0.99", "mid_price <= 1.01"]);
268 assert_eq!(notes.len(), 1);
269 assert!(notes[0].contains(ALERTS_ISSUE), "{notes:?}");
270 let (w, _) = from_with("monitor PEPE", &config).unwrap();
271 let rules: Vec<String> = w.targets[0].rules.iter().map(ToString::to_string).collect();
272 assert!(
273 rules.contains(&"price_usd >= 0.99".to_string()),
274 "{rules:?}"
275 );
276 }
277}