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 issue that brings back the whale and volume-spike alerts (Q2).
12pub const ALERTS_ISSUE: &str = "#122";
13
14/// The note for a dashboard-only flag the wizard does not use.
15fn 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
21/// The watchlist the monitor arguments describe, with the notes the user
22/// should read (flags that do nothing, alerts that are dropped).
23///
24/// # Errors
25///
26/// An interval or duration that does not parse, or an unknown preset.
27pub 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            // The price and volume charts of the old dashboard.
87            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    // The monitor's alerts: price bounds become rules on the price the
109    // target has; whale and volume-spike alerts have no metric yet.
110    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}