Skip to main content

scope/watch/
rule.rs

1//! Rules (`metric op value`) and their evaluation.
2
3use crate::metrics::MetricId;
4use std::fmt;
5
6/// A comparison.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Op {
9    Lt,
10    Le,
11    Gt,
12    Ge,
13    Eq,
14    Ne,
15}
16
17impl Op {
18    /// Every operator, longest symbol first (so `<=` parses before `<`).
19    const BY_LENGTH: [Op; 6] = [Op::Le, Op::Ge, Op::Eq, Op::Ne, Op::Lt, Op::Gt];
20
21    /// The operator's symbol.
22    pub fn symbol(self) -> &'static str {
23        match self {
24            Op::Lt => "<",
25            Op::Le => "<=",
26            Op::Gt => ">",
27            Op::Ge => ">=",
28            Op::Eq => "==",
29            Op::Ne => "!=",
30        }
31    }
32
33    /// The operator with this symbol.
34    pub fn from_symbol(s: &str) -> Option<Op> {
35        Op::BY_LENGTH.iter().copied().find(|op| op.symbol() == s)
36    }
37
38    /// Whether `value op limit` holds. `==` and `!=` allow a relative
39    /// difference of 1e-9, so a value read back from text still matches.
40    pub fn holds(self, value: f64, limit: f64) -> bool {
41        let equal = (value - limit).abs() <= 1e-9 * value.abs().max(limit.abs()).max(1.0);
42        match self {
43            Op::Lt => value < limit,
44            Op::Le => value <= limit || equal,
45            Op::Gt => value > limit,
46            Op::Ge => value >= limit || equal,
47            Op::Eq => equal,
48            Op::Ne => !equal,
49        }
50    }
51}
52
53impl fmt::Display for Op {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.write_str(self.symbol())
56    }
57}
58
59/// A metric threshold: the target passes the rule when `metric op value`.
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct Rule {
62    pub metric: MetricId,
63    pub op: Op,
64    pub value: f64,
65}
66
67impl Rule {
68    /// Parses `<metric> <op> <value>`, for example `holders >= 1000`. The
69    /// spaces are optional.
70    pub fn parse(text: &str) -> Result<Rule, String> {
71        let text = text.trim();
72        let (at, op) = text
73            .char_indices()
74            .find_map(|(i, _)| {
75                Op::BY_LENGTH
76                    .iter()
77                    .find(|op| text[i..].starts_with(op.symbol()))
78                    .map(|op| (i, *op))
79            })
80            .ok_or_else(|| format!("`{text}` has no operator; use one of < <= > >= == !="))?;
81        let name = text[..at].trim();
82        let value_text = text[at + op.symbol().len()..].trim();
83        let metric = MetricId::from_name(name).ok_or_else(|| {
84            let names: Vec<&str> = MetricId::ALL.iter().map(|m| m.name()).collect();
85            format!(
86                "unknown metric `{name}`; the metrics are {}",
87                names.join(", ")
88            )
89        })?;
90        let value: f64 = value_text
91            .replace('_', "")
92            .parse()
93            .ok()
94            .filter(|v: &f64| v.is_finite())
95            .ok_or_else(|| format!("`{value_text}` is not a number"))?;
96        Ok(Rule { metric, op, value })
97    }
98
99    /// Judges `value` against the rule. No value is an error: health is
100    /// unknown, never a pass.
101    pub fn eval(&self, value: Option<f64>) -> RuleStatus {
102        match value {
103            None => RuleStatus::Error(format!("{}: no value", self.metric.name())),
104            Some(v) if self.op.holds(v, self.value) => RuleStatus::Pass,
105            Some(v) => RuleStatus::Fail(format!(
106                "{} {}, needs {} {}",
107                self.metric.name(),
108                trim_number(v),
109                self.op,
110                trim_number(self.value)
111            )),
112        }
113    }
114}
115
116impl fmt::Display for Rule {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        write!(
119            f,
120            "{} {} {}",
121            self.metric.name(),
122            self.op,
123            trim_number(self.value)
124        )
125    }
126}
127
128/// A number as short as it reads back: `1000`, `0.5`, `3.4`.
129fn trim_number(v: f64) -> String {
130    if v.fract() == 0.0 && v.abs() < 1e15 {
131        format!("{v:.0}")
132    } else {
133        let s = format!("{v:.6}");
134        s.trim_end_matches('0').trim_end_matches('.').to_string()
135    }
136}
137
138/// The result of one rule or preset check on one tick.
139#[derive(Debug, Clone, PartialEq)]
140pub enum RuleStatus {
141    Pass,
142    /// A preset check warned (P2).
143    Warn(String),
144    Fail(String),
145    /// The check does not apply here, for example spread on an AMM book.
146    NotApplicable(String),
147    /// The value could not be read: health is unknown.
148    Error(String),
149}
150
151impl RuleStatus {
152    /// The status's short name: `pass`, `warn`, `fail`, `n/a`, `error`.
153    pub fn name(&self) -> &'static str {
154        match self {
155            RuleStatus::Pass => "pass",
156            RuleStatus::Warn(_) => "warn",
157            RuleStatus::Fail(_) => "fail",
158            RuleStatus::NotApplicable(_) => "n/a",
159            RuleStatus::Error(_) => "error",
160        }
161    }
162
163    /// The message, when the status has one.
164    pub fn message(&self) -> Option<&str> {
165        match self {
166            RuleStatus::Pass => None,
167            RuleStatus::Warn(m)
168            | RuleStatus::Fail(m)
169            | RuleStatus::NotApplicable(m)
170            | RuleStatus::Error(m) => Some(m),
171        }
172    }
173}
174
175/// A target's state, in the order the grid sorts rows.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
177pub enum TargetStatus {
178    /// At least one rule failed.
179    Breached,
180    /// No failure, no error, at least one warning.
181    Warned,
182    /// No failure, but at least one rule could not be read.
183    Unknown,
184    /// Every rule that applies passed.
185    Passed,
186}
187
188impl TargetStatus {
189    /// The status's name as the grid shows it.
190    pub fn name(self) -> &'static str {
191        match self {
192            TargetStatus::Breached => "breached",
193            TargetStatus::Warned => "warned",
194            TargetStatus::Unknown => "unknown",
195            TargetStatus::Passed => "passed",
196        }
197    }
198}
199
200/// A target's state from its rule statuses. `n/a` never counts against it.
201pub fn target_status(statuses: &[RuleStatus]) -> TargetStatus {
202    let any = |f: fn(&RuleStatus) -> bool| statuses.iter().any(f);
203    if any(|s| matches!(s, RuleStatus::Fail(_))) {
204        TargetStatus::Breached
205    } else if any(|s| matches!(s, RuleStatus::Error(_))) {
206        TargetStatus::Unknown
207    } else if any(|s| matches!(s, RuleStatus::Warn(_))) {
208        TargetStatus::Warned
209    } else {
210        TargetStatus::Passed
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn every_op_below_at_and_above_the_limit() {
220        // (op, below, at, above)
221        let table = [
222            (Op::Lt, true, false, false),
223            (Op::Le, true, true, false),
224            (Op::Gt, false, false, true),
225            (Op::Ge, false, true, true),
226            (Op::Eq, false, true, false),
227            (Op::Ne, true, false, true),
228        ];
229        for (op, below, at, above) in table {
230            assert_eq!(op.holds(0.9, 1.0), below, "{op} below");
231            assert_eq!(op.holds(1.0, 1.0), at, "{op} at");
232            assert_eq!(op.holds(1.1, 1.0), above, "{op} above");
233        }
234        assert!(Op::Eq.holds(0.1 + 0.2, 0.3), "float noise is equal");
235    }
236
237    #[test]
238    fn rules_parse_with_and_without_spaces_and_print_back() {
239        for (text, metric, op, value) in [
240            ("holders >= 1000", MetricId::Holders, Op::Ge, 1000.0),
241            ("spread_pct<0.5", MetricId::SpreadPct, Op::Lt, 0.5),
242            (
243                "liquidity_usd > 1_000_000",
244                MetricId::LiquidityUsd,
245                Op::Gt,
246                1e6,
247            ),
248            ("risk_score != 10", MetricId::RiskScore, Op::Ne, 10.0),
249            ("mid_price == 1", MetricId::Mid, Op::Eq, 1.0),
250            (
251                "native_balance <= -1",
252                MetricId::NativeBalance,
253                Op::Le,
254                -1.0,
255            ),
256        ] {
257            let r = Rule::parse(text).unwrap();
258            assert_eq!((r.metric, r.op, r.value), (metric, op, value), "{text}");
259            assert_eq!(Rule::parse(&r.to_string()).unwrap(), r);
260        }
261        assert_eq!(
262            Rule::parse("holders >= 1000").unwrap().to_string(),
263            "holders >= 1000"
264        );
265    }
266
267    #[test]
268    fn bad_rules_say_why() {
269        assert!(
270            Rule::parse("holders 1000")
271                .unwrap_err()
272                .contains("no operator")
273        );
274        let e = Rule::parse("holdrs > 1").unwrap_err();
275        assert!(
276            e.contains("unknown metric `holdrs`") && e.contains("holders"),
277            "{e}"
278        );
279        assert!(
280            Rule::parse("holders > lots")
281                .unwrap_err()
282                .contains("not a number")
283        );
284        assert!(
285            Rule::parse("holders > inf")
286                .unwrap_err()
287                .contains("not a number")
288        );
289    }
290
291    #[test]
292    fn eval_gives_pass_fail_and_error_for_no_value() {
293        let r = Rule::parse("spread_pct <= 3").unwrap();
294        assert_eq!(r.eval(Some(1.0)), RuleStatus::Pass);
295        assert_eq!(
296            r.eval(Some(3.4)),
297            RuleStatus::Fail("spread_pct 3.4, needs <= 3".into())
298        );
299        assert_eq!(
300            r.eval(None),
301            RuleStatus::Error("spread_pct: no value".into())
302        );
303    }
304
305    #[test]
306    fn target_status_rules() {
307        use RuleStatus::*;
308        let s = |v: &[RuleStatus]| target_status(v);
309        assert_eq!(
310            s(&[Pass, Fail("f".into()), Error("e".into())]),
311            TargetStatus::Breached
312        );
313        assert_eq!(
314            s(&[Pass, Error("e".into()), Warn("w".into())]),
315            TargetStatus::Unknown
316        );
317        assert_eq!(s(&[Pass, Warn("w".into())]), TargetStatus::Warned);
318        assert_eq!(s(&[NotApplicable("n".into())]), TargetStatus::Passed);
319        assert_eq!(s(&[]), TargetStatus::Passed);
320        let mut order = vec![
321            TargetStatus::Passed,
322            TargetStatus::Unknown,
323            TargetStatus::Breached,
324            TargetStatus::Warned,
325        ];
326        order.sort();
327        assert_eq!(
328            order,
329            [
330                TargetStatus::Breached,
331                TargetStatus::Warned,
332                TargetStatus::Unknown,
333                TargetStatus::Passed
334            ]
335        );
336    }
337}