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