use crate::metrics::MetricId;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Op {
Lt,
Le,
Gt,
Ge,
Eq,
Ne,
}
impl Op {
const BY_LENGTH: [Op; 6] = [Op::Le, Op::Ge, Op::Eq, Op::Ne, Op::Lt, Op::Gt];
pub fn symbol(self) -> &'static str {
match self {
Op::Lt => "<",
Op::Le => "<=",
Op::Gt => ">",
Op::Ge => ">=",
Op::Eq => "==",
Op::Ne => "!=",
}
}
pub fn from_symbol(s: &str) -> Option<Op> {
Op::BY_LENGTH.iter().copied().find(|op| op.symbol() == s)
}
pub fn holds(self, value: f64, limit: f64) -> bool {
let equal = (value - limit).abs() <= 1e-9 * value.abs().max(limit.abs()).max(1.0);
match self {
Op::Lt => value < limit,
Op::Le => value <= limit || equal,
Op::Gt => value > limit,
Op::Ge => value >= limit || equal,
Op::Eq => equal,
Op::Ne => !equal,
}
}
}
impl fmt::Display for Op {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.symbol())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rule {
pub metric: MetricId,
pub op: Op,
pub value: f64,
}
impl Rule {
pub fn parse(text: &str) -> Result<Rule, String> {
let text = text.trim();
let (at, op) = text
.char_indices()
.find_map(|(i, _)| {
Op::BY_LENGTH
.iter()
.find(|op| text[i..].starts_with(op.symbol()))
.map(|op| (i, *op))
})
.ok_or_else(|| format!("`{text}` has no operator; use one of < <= > >= == !="))?;
let name = text[..at].trim();
let value_text = text[at + op.symbol().len()..].trim();
let metric = MetricId::from_name(name).ok_or_else(|| {
let names: Vec<&str> = MetricId::ALL.iter().map(|m| m.name()).collect();
format!(
"unknown metric `{name}`; the metrics are {}",
names.join(", ")
)
})?;
let value: f64 = value_text
.replace('_', "")
.parse()
.ok()
.filter(|v: &f64| v.is_finite())
.ok_or_else(|| format!("`{value_text}` is not a number"))?;
Ok(Rule { metric, op, value })
}
pub fn eval(&self, value: Option<f64>) -> RuleStatus {
match value {
None => RuleStatus::Error(format!("{}: no value", self.metric.name())),
Some(v) if self.op.holds(v, self.value) => RuleStatus::Pass,
Some(v) => RuleStatus::Fail(format!(
"{} {}, needs {} {}",
self.metric.name(),
trim_number(v),
self.op,
trim_number(self.value)
)),
}
}
}
impl fmt::Display for Rule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} {} {}",
self.metric.name(),
self.op,
trim_number(self.value)
)
}
}
fn trim_number(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 {
format!("{v:.0}")
} else {
let s = format!("{v:.6}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RuleStatus {
Pass,
Warn(String),
Fail(String),
NotApplicable(String),
Error(String),
}
impl RuleStatus {
pub fn name(&self) -> &'static str {
match self {
RuleStatus::Pass => "pass",
RuleStatus::Warn(_) => "warn",
RuleStatus::Fail(_) => "fail",
RuleStatus::NotApplicable(_) => "n/a",
RuleStatus::Error(_) => "error",
}
}
pub fn message(&self) -> Option<&str> {
match self {
RuleStatus::Pass => None,
RuleStatus::Warn(m)
| RuleStatus::Fail(m)
| RuleStatus::NotApplicable(m)
| RuleStatus::Error(m) => Some(m),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TargetStatus {
Breached,
Warned,
Unknown,
Passed,
}
impl TargetStatus {
pub fn name(self) -> &'static str {
match self {
TargetStatus::Breached => "breached",
TargetStatus::Warned => "warned",
TargetStatus::Unknown => "unknown",
TargetStatus::Passed => "passed",
}
}
}
pub fn target_status(statuses: &[RuleStatus]) -> TargetStatus {
let any = |f: fn(&RuleStatus) -> bool| statuses.iter().any(f);
if any(|s| matches!(s, RuleStatus::Fail(_))) {
TargetStatus::Breached
} else if any(|s| matches!(s, RuleStatus::Error(_))) {
TargetStatus::Unknown
} else if any(|s| matches!(s, RuleStatus::Warn(_))) {
TargetStatus::Warned
} else {
TargetStatus::Passed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_op_below_at_and_above_the_limit() {
let table = [
(Op::Lt, true, false, false),
(Op::Le, true, true, false),
(Op::Gt, false, false, true),
(Op::Ge, false, true, true),
(Op::Eq, false, true, false),
(Op::Ne, true, false, true),
];
for (op, below, at, above) in table {
assert_eq!(op.holds(0.9, 1.0), below, "{op} below");
assert_eq!(op.holds(1.0, 1.0), at, "{op} at");
assert_eq!(op.holds(1.1, 1.0), above, "{op} above");
}
assert!(Op::Eq.holds(0.1 + 0.2, 0.3), "float noise is equal");
}
#[test]
fn rules_parse_with_and_without_spaces_and_print_back() {
for (text, metric, op, value) in [
("holders >= 1000", MetricId::Holders, Op::Ge, 1000.0),
("spread_pct<0.5", MetricId::SpreadPct, Op::Lt, 0.5),
(
"liquidity_usd > 1_000_000",
MetricId::LiquidityUsd,
Op::Gt,
1e6,
),
("risk_score != 10", MetricId::RiskScore, Op::Ne, 10.0),
("mid_price == 1", MetricId::Mid, Op::Eq, 1.0),
(
"native_balance <= -1",
MetricId::NativeBalance,
Op::Le,
-1.0,
),
] {
let r = Rule::parse(text).unwrap();
assert_eq!((r.metric, r.op, r.value), (metric, op, value), "{text}");
assert_eq!(Rule::parse(&r.to_string()).unwrap(), r);
}
assert_eq!(
Rule::parse("holders >= 1000").unwrap().to_string(),
"holders >= 1000"
);
}
#[test]
fn bad_rules_say_why() {
assert!(
Rule::parse("holders 1000")
.unwrap_err()
.contains("no operator")
);
let e = Rule::parse("holdrs > 1").unwrap_err();
assert!(
e.contains("unknown metric `holdrs`") && e.contains("holders"),
"{e}"
);
assert!(
Rule::parse("holders > lots")
.unwrap_err()
.contains("not a number")
);
assert!(
Rule::parse("holders > inf")
.unwrap_err()
.contains("not a number")
);
}
#[test]
fn eval_gives_pass_fail_and_error_for_no_value() {
let r = Rule::parse("spread_pct <= 3").unwrap();
assert_eq!(r.eval(Some(1.0)), RuleStatus::Pass);
assert_eq!(
r.eval(Some(3.4)),
RuleStatus::Fail("spread_pct 3.4, needs <= 3".into())
);
assert_eq!(
r.eval(None),
RuleStatus::Error("spread_pct: no value".into())
);
}
#[test]
fn target_status_rules() {
use RuleStatus::*;
let s = |v: &[RuleStatus]| target_status(v);
assert_eq!(
s(&[Pass, Fail("f".into()), Error("e".into())]),
TargetStatus::Breached
);
assert_eq!(
s(&[Pass, Error("e".into()), Warn("w".into())]),
TargetStatus::Unknown
);
assert_eq!(s(&[Pass, Warn("w".into())]), TargetStatus::Warned);
assert_eq!(s(&[NotApplicable("n".into())]), TargetStatus::Passed);
assert_eq!(s(&[]), TargetStatus::Passed);
let mut order = vec![
TargetStatus::Passed,
TargetStatus::Unknown,
TargetStatus::Breached,
TargetStatus::Warned,
];
order.sort();
assert_eq!(
order,
[
TargetStatus::Breached,
TargetStatus::Warned,
TargetStatus::Unknown,
TargetStatus::Passed
]
);
}
}