1pub mod diff;
14pub mod file;
15pub mod preset;
16pub mod rule;
17pub mod time;
18
19pub use diff::{Event, diff};
20pub use rule::{Op, Rule, RuleStatus, TargetStatus, target_status};
21
22use crate::market::HealthOverrides;
23use crate::metrics::{MetricGroup, MetricId};
24use std::path::PathBuf;
25use std::time::Duration;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum TargetKind {
31 Token,
33 Pair,
35 Address,
37}
38
39impl TargetKind {
40 pub fn accepts(self, group: MetricGroup) -> bool {
42 matches!(
43 (self, group),
44 (TargetKind::Token | TargetKind::Pair, MetricGroup::Market)
45 | (TargetKind::Token, MetricGroup::Token)
46 | (TargetKind::Address, MetricGroup::Address)
47 )
48 }
49
50 pub fn metrics(self) -> Vec<MetricId> {
52 MetricId::ALL
53 .iter()
54 .copied()
55 .filter(|id| self.accepts(id.group()))
56 .collect()
57 }
58
59 pub fn name(self) -> &'static str {
61 match self {
62 TargetKind::Token => "token",
63 TargetKind::Pair => "pair",
64 TargetKind::Address => "address",
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum Preset {
72 MarketHealth,
74}
75
76impl Preset {
77 pub const ALL: &'static [Preset] = &[Preset::MarketHealth];
79
80 pub fn name(self) -> &'static str {
82 match self {
83 Preset::MarketHealth => "market health",
84 }
85 }
86
87 pub fn from_name(name: &str) -> Option<Preset> {
89 let name = name.trim().replace(['-', '_'], " ");
90 Preset::ALL
91 .iter()
92 .copied()
93 .find(|p| p.name().eq_ignore_ascii_case(&name))
94 }
95}
96
97#[derive(Debug, Clone, PartialEq)]
99pub struct Target {
100 pub kind: TargetKind,
101 pub id: String,
103 pub chain: Option<String>,
105 pub venue: Option<String>,
107 pub preset: Option<Preset>,
109 pub health: HealthOverrides,
111 pub rules: Vec<Rule>,
112}
113
114impl Target {
115 pub fn new(kind: TargetKind, id: impl Into<String>) -> Self {
117 Self {
118 kind,
119 id: id.into(),
120 chain: None,
121 venue: None,
122 preset: None,
123 health: HealthOverrides::default(),
124 rules: Vec::new(),
125 }
126 }
127
128 pub fn label(&self) -> String {
130 match &self.venue {
131 Some(venue) => format!("{}@{}", self.id, venue),
132 None => self.id.clone(),
133 }
134 }
135
136 pub fn needs_market(&self) -> bool {
138 self.preset == Some(Preset::MarketHealth)
139 || self
140 .rules
141 .iter()
142 .any(|r| r.metric.group() == MetricGroup::Market)
143 }
144
145 pub fn needs(&self, group: MetricGroup) -> bool {
147 self.rules.iter().any(|r| r.metric.group() == group)
148 }
149}
150
151#[derive(Debug, Clone, Default, PartialEq)]
153pub struct Outputs {
154 pub csv: Option<PathBuf>,
156 pub report: Option<PathBuf>,
158}
159
160#[derive(Debug, Clone, PartialEq)]
162pub struct Watchlist {
163 pub name: String,
164 pub interval: Duration,
166 pub duration: Option<Duration>,
168 pub outputs: Outputs,
169 pub targets: Vec<Target>,
170}
171
172impl Watchlist {
173 pub fn new(name: impl Into<String>) -> Self {
175 Self {
176 name: name.into(),
177 interval: Duration::from_secs(time::DEFAULT_INTERVAL_SECS),
178 duration: Some(Duration::from_secs(time::DEFAULT_DURATION_SECS)),
179 outputs: Outputs::default(),
180 targets: Vec::new(),
181 }
182 }
183
184 pub fn fetches_per_tick(&self) -> usize {
186 self.targets
187 .iter()
188 .map(|t| {
189 usize::from(t.needs_market())
190 + usize::from(t.needs(MetricGroup::Token))
191 + usize::from(t.needs(MetricGroup::Address))
192 })
193 .sum()
194 }
195
196 pub fn ticks(&self) -> Option<u64> {
199 let every = self.interval.as_millis().max(1);
200 let total = self.duration?.as_millis().div_ceil(every).saturating_add(1);
201 Some(u64::try_from(total).unwrap_or(u64::MAX))
202 }
203}
204
205#[derive(Debug, Clone, PartialEq)]
207pub struct Cell {
208 pub label: String,
210 pub rule: Option<Rule>,
212 pub value: Option<f64>,
214 pub status: RuleStatus,
215 pub detail: Option<String>,
218}
219
220#[derive(Debug, Clone, PartialEq)]
222pub struct TargetSnapshot {
223 pub target: String,
225 pub cells: Vec<Cell>,
226 pub status: TargetStatus,
227}
228
229impl TargetSnapshot {
230 pub fn new(target: impl Into<String>, cells: Vec<Cell>) -> Self {
232 let statuses: Vec<RuleStatus> = cells.iter().map(|c| c.status.clone()).collect();
233 Self {
234 target: target.into(),
235 status: target_status(&statuses),
236 cells,
237 }
238 }
239}
240
241#[derive(Debug, Clone, Default, PartialEq)]
243pub struct Snapshot {
244 pub targets: Vec<TargetSnapshot>,
245}
246
247impl Snapshot {
248 pub fn breaches(&self) -> usize {
250 self.targets
251 .iter()
252 .filter(|t| t.status == TargetStatus::Breached)
253 .count()
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn kinds_accept_their_metric_groups() {
263 use MetricGroup::*;
264 assert!(TargetKind::Pair.accepts(Market));
265 assert!(!TargetKind::Pair.accepts(Token));
266 assert!(TargetKind::Token.accepts(Market));
267 assert!(TargetKind::Token.accepts(Token));
268 assert!(!TargetKind::Token.accepts(Address));
269 assert!(TargetKind::Address.accepts(Address));
270 assert!(!TargetKind::Address.accepts(Market));
271 assert!(TargetKind::Address.metrics().contains(&MetricId::RiskScore));
272 assert!(!TargetKind::Pair.metrics().contains(&MetricId::Holders));
273 }
274
275 #[test]
276 fn preset_names_parse_in_several_spellings() {
277 for s in [
278 "market health",
279 "Market Health",
280 "market-health",
281 "market_health",
282 ] {
283 assert_eq!(Preset::from_name(s), Some(Preset::MarketHealth), "{s}");
284 }
285 assert_eq!(Preset::from_name("peg"), None);
286 }
287
288 #[test]
289 fn label_names_the_venue() {
290 let mut t = Target::new(TargetKind::Pair, "USDC");
291 assert_eq!(t.label(), "USDC");
292 t.venue = Some("binance".into());
293 assert_eq!(t.label(), "USDC@binance");
294 }
295
296 #[test]
297 fn fetches_count_each_source_once_per_target() {
298 let mut w = Watchlist::new("w");
299 let mut pair = Target::new(TargetKind::Pair, "USDC");
300 pair.preset = Some(Preset::MarketHealth);
301 pair.rules.push(Rule::parse("spread_pct < 0.5").unwrap());
302 let mut token = Target::new(TargetKind::Token, "PEPE");
303 token.rules.push(Rule::parse("holders >= 10").unwrap());
304 token.rules.push(Rule::parse("price_usd > 0").unwrap());
305 w.targets = vec![pair, token];
306 assert_eq!(w.fetches_per_tick(), 2);
307 }
308
309 #[test]
310 fn defaults_are_market_summary_defaults_and_ticks_follow_the_runner() {
311 let w = Watchlist::new("w");
312 assert_eq!(w.interval, Duration::from_secs(60));
313 assert_eq!(w.duration, Some(Duration::from_secs(3600)));
314 assert_eq!(w.ticks(), Some(61));
315 let w = Watchlist {
316 duration: None,
317 ..Watchlist::new("w")
318 };
319 assert_eq!(w.ticks(), None);
320 }
321
322 #[test]
323 fn snapshot_status_comes_from_the_cells() {
324 let cell = |status| Cell {
325 label: "x".into(),
326 rule: None,
327 value: None,
328 status,
329 detail: None,
330 };
331 let row = TargetSnapshot::new(
332 "A",
333 vec![cell(RuleStatus::Pass), cell(RuleStatus::Fail("f".into()))],
334 );
335 assert_eq!(row.status, TargetStatus::Breached);
336 let s = Snapshot {
337 targets: vec![row, TargetSnapshot::new("B", vec![cell(RuleStatus::Pass)])],
338 };
339 assert_eq!(s.breaches(), 1);
340 }
341}