Skip to main content

scope_cli/cli/watch/
monitor.rs

1//! `scope monitor <args>` as a watchlist: the arguments fill the monitor
2//! wizard (#47, design section 5.3).
3
4use 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
11/// The note for a dashboard-only flag the wizard does not use.
12fn 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
18/// The watchlist the monitor arguments describe, with the notes the user
19/// should read (flags that do nothing, alerts that are dropped).
20///
21/// # Errors
22///
23/// An interval or duration that does not parse, or an unknown preset.
24pub 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            // The price and volume charts of the old dashboard.
84            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    // The monitor's alerts become rules: price bounds on the price the
106    // target has; a whale or a volume spike on the venue's trades and
107    // volume since the last tick. A rule says what must hold, so an alert
108    // at a limit is a rule below it.
109    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    // Design 2026-11-04, section 2.8: one token watched on several venues.
198    #[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        // Neither alert set: no note.
323        let (_, notes) = from_with("monitor PEPE", &Config::default()).unwrap();
324        assert!(notes.is_empty(), "{notes:?}");
325    }
326}