1pub mod diff;
15pub mod file;
16pub mod history;
17pub mod preset;
18pub mod rule;
19pub mod time;
20
21pub use diff::{Event, diff};
22pub use history::{History, Reading, Readings};
23pub use rule::{Op, Rule, RuleStatus, TargetStatus, target_status};
24
25use crate::market::HealthOverrides;
26use crate::metrics::{MetricGroup, MetricId};
27use std::path::PathBuf;
28use std::time::Duration;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum TargetKind {
34 Token,
36 Pair,
38 Address,
40}
41
42impl TargetKind {
43 pub fn accepts(self, group: MetricGroup) -> bool {
45 matches!(
46 (self, group),
47 (TargetKind::Token | TargetKind::Pair, MetricGroup::Market)
48 | (TargetKind::Token, MetricGroup::Token)
49 | (TargetKind::Address, MetricGroup::Address)
50 )
51 }
52
53 pub fn metrics(self) -> Vec<MetricId> {
55 MetricId::ALL
56 .iter()
57 .copied()
58 .filter(|id| self.accepts(id.group()))
59 .collect()
60 }
61
62 pub fn name(self) -> &'static str {
64 match self {
65 TargetKind::Token => "token",
66 TargetKind::Pair => "pair",
67 TargetKind::Address => "address",
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Preset {
75 MarketHealth,
77}
78
79impl Preset {
80 pub const ALL: &'static [Preset] = &[Preset::MarketHealth];
82
83 pub fn name(self) -> &'static str {
85 match self {
86 Preset::MarketHealth => "market health",
87 }
88 }
89
90 pub fn from_name(name: &str) -> Option<Preset> {
92 let name = name.trim().replace(['-', '_'], " ");
93 Preset::ALL
94 .iter()
95 .copied()
96 .find(|p| p.name().eq_ignore_ascii_case(&name))
97 }
98}
99
100#[derive(Debug, Clone, PartialEq)]
102pub struct Target {
103 pub kind: TargetKind,
104 pub id: String,
106 pub chain: Option<String>,
108 pub venues: Vec<String>,
110 pub preset: Option<Preset>,
112 pub health: HealthOverrides,
114 pub rules: Vec<Rule>,
115}
116
117impl Target {
118 pub fn new(kind: TargetKind, id: impl Into<String>) -> Self {
120 Self {
121 kind,
122 id: id.into(),
123 chain: None,
124 venues: Vec::new(),
125 preset: None,
126 health: HealthOverrides::default(),
127 rules: Vec::new(),
128 }
129 }
130
131 pub fn label(&self) -> String {
134 if self.venues.is_empty() {
135 self.id.clone()
136 } else {
137 format!("{}@{}", self.id, self.venues.join(","))
138 }
139 }
140
141 pub fn row_label(&self, venue: Option<&str>) -> String {
144 match venue {
145 Some(venue) => format!("{}@{}", self.id, venue),
146 None => self.id.clone(),
147 }
148 }
149
150 pub fn rows(&self) -> Vec<Option<&str>> {
156 match self.venues.as_slice() {
157 [] => vec![None],
158 [one] => vec![Some(one.as_str())],
159 _ if !self.needs_market() => vec![None],
160 many => {
161 let mut rows: Vec<Option<&str>> = many.iter().map(|v| Some(v.as_str())).collect();
162 if self.needs(MetricGroup::Token) || self.needs(MetricGroup::Address) {
163 rows.push(None);
164 }
165 rows
166 }
167 }
168 }
169
170 pub fn needs_market(&self) -> bool {
172 self.preset == Some(Preset::MarketHealth)
173 || self
174 .rules
175 .iter()
176 .any(|r| r.metric.group() == MetricGroup::Market)
177 }
178
179 pub fn needs(&self, group: MetricGroup) -> bool {
181 self.rules.iter().any(|r| r.metric.group() == group)
182 }
183}
184
185#[derive(Debug, Clone, Default, PartialEq)]
187pub struct Outputs {
188 pub csv: Option<PathBuf>,
190 pub report: Option<PathBuf>,
192}
193
194#[derive(Debug, Clone, PartialEq)]
196pub struct Watchlist {
197 pub name: String,
198 pub interval: Duration,
200 pub duration: Option<Duration>,
202 pub outputs: Outputs,
203 pub targets: Vec<Target>,
204}
205
206impl Watchlist {
207 pub fn new(name: impl Into<String>) -> Self {
209 Self {
210 name: name.into(),
211 interval: Duration::from_secs(time::DEFAULT_INTERVAL_SECS),
212 duration: Some(Duration::from_secs(time::DEFAULT_DURATION_SECS)),
213 outputs: Outputs::default(),
214 targets: Vec::new(),
215 }
216 }
217
218 pub fn fetches_per_tick(&self) -> usize {
220 self.targets
221 .iter()
222 .map(|t| {
223 usize::from(t.needs_market()) * t.venues.len().max(1)
224 + usize::from(t.needs(MetricGroup::Token))
225 + usize::from(t.needs(MetricGroup::Address))
226 })
227 .sum()
228 }
229
230 pub fn ticks(&self) -> Option<u64> {
233 let every = self.interval.as_millis().max(1);
234 let total = self.duration?.as_millis().div_ceil(every).saturating_add(1);
235 Some(u64::try_from(total).unwrap_or(u64::MAX))
236 }
237}
238
239#[derive(Debug, Clone, PartialEq)]
241pub struct Cell {
242 pub label: String,
244 pub rule: Option<Rule>,
246 pub value: Option<f64>,
248 pub status: RuleStatus,
249 pub detail: Option<String>,
252}
253
254#[derive(Debug, Clone, PartialEq)]
256pub struct TargetSnapshot {
257 pub target: String,
259 pub venue: Option<String>,
261 pub cells: Vec<Cell>,
262 pub status: TargetStatus,
263}
264
265impl TargetSnapshot {
266 pub fn new(target: impl Into<String>, cells: Vec<Cell>) -> Self {
268 let statuses: Vec<RuleStatus> = cells.iter().map(|c| c.status.clone()).collect();
269 Self {
270 target: target.into(),
271 venue: None,
272 status: target_status(&statuses),
273 cells,
274 }
275 }
276
277 pub fn on_venue(mut self, venue: Option<&str>) -> Self {
279 self.venue = venue.map(str::to_string);
280 self
281 }
282}
283
284#[derive(Debug, Clone, Default, PartialEq)]
286pub struct Snapshot {
287 pub targets: Vec<TargetSnapshot>,
288}
289
290impl Snapshot {
291 pub fn breaches(&self) -> usize {
293 self.targets
294 .iter()
295 .filter(|t| t.status == TargetStatus::Breached)
296 .count()
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn kinds_accept_their_metric_groups() {
306 use MetricGroup::*;
307 assert!(TargetKind::Pair.accepts(Market));
308 assert!(!TargetKind::Pair.accepts(Token));
309 assert!(TargetKind::Token.accepts(Market));
310 assert!(TargetKind::Token.accepts(Token));
311 assert!(!TargetKind::Token.accepts(Address));
312 assert!(TargetKind::Address.accepts(Address));
313 assert!(!TargetKind::Address.accepts(Market));
314 assert!(TargetKind::Address.metrics().contains(&MetricId::RiskScore));
315 assert!(!TargetKind::Pair.metrics().contains(&MetricId::Holders));
316 }
317
318 #[test]
319 fn preset_names_parse_in_several_spellings() {
320 for s in [
321 "market health",
322 "Market Health",
323 "market-health",
324 "market_health",
325 ] {
326 assert_eq!(Preset::from_name(s), Some(Preset::MarketHealth), "{s}");
327 }
328 assert_eq!(Preset::from_name("peg"), None);
329 }
330
331 #[test]
332 fn label_names_the_venues() {
333 let mut t = Target::new(TargetKind::Pair, "USDC");
334 assert_eq!(t.label(), "USDC");
335 t.venues = vec!["binance".into()];
336 assert_eq!(t.label(), "USDC@binance");
337 t.venues.push("okx".into());
338 assert_eq!(t.label(), "USDC@binance,okx");
339 assert_eq!(t.row_label(Some("okx")), "USDC@okx");
340 assert_eq!(t.row_label(None), "USDC");
341 }
342
343 #[test]
347 fn rows_split_by_venue_and_keep_venue_free_cells_apart() {
348 let mut t = Target::new(TargetKind::Token, "USDN");
349 assert_eq!(t.rows(), vec![None]);
350 t.venues = vec!["weex".into()];
351 t.rules.push(Rule::parse("holders >= 10").unwrap());
352 assert_eq!(t.rows(), vec![Some("weex")]);
353 t.venues.push("binance".into());
354 t.preset = Some(Preset::MarketHealth);
355 assert_eq!(t.rows(), vec![Some("weex"), Some("binance"), None]);
356 t.rules.clear();
357 assert_eq!(t.rows(), vec![Some("weex"), Some("binance")]);
358 t.preset = None;
360 t.rules.push(Rule::parse("holders >= 10").unwrap());
361 assert_eq!(t.rows(), vec![None]);
362 }
363
364 #[test]
365 fn fetches_count_each_source_once_per_target() {
366 let mut w = Watchlist::new("w");
367 let mut pair = Target::new(TargetKind::Pair, "USDC");
368 pair.preset = Some(Preset::MarketHealth);
369 pair.rules.push(Rule::parse("spread_pct < 0.5").unwrap());
370 let mut token = Target::new(TargetKind::Token, "PEPE");
371 token.rules.push(Rule::parse("holders >= 10").unwrap());
372 token.rules.push(Rule::parse("price_usd > 0").unwrap());
373 w.targets = vec![pair, token];
374 assert_eq!(w.fetches_per_tick(), 2);
375 w.targets[0].venues = vec!["binance".into(), "okx".into()];
377 assert_eq!(w.fetches_per_tick(), 3);
378 }
379
380 #[test]
381 fn defaults_are_market_summary_defaults_and_ticks_follow_the_runner() {
382 let w = Watchlist::new("w");
383 assert_eq!(w.interval, Duration::from_secs(60));
384 assert_eq!(w.duration, Some(Duration::from_secs(3600)));
385 assert_eq!(w.ticks(), Some(61));
386 let w = Watchlist {
387 duration: None,
388 ..Watchlist::new("w")
389 };
390 assert_eq!(w.ticks(), None);
391 }
392
393 #[test]
394 fn snapshot_status_comes_from_the_cells() {
395 let cell = |status| Cell {
396 label: "x".into(),
397 rule: None,
398 value: None,
399 status,
400 detail: None,
401 };
402 let row = TargetSnapshot::new(
403 "A",
404 vec![cell(RuleStatus::Pass), cell(RuleStatus::Fail("f".into()))],
405 );
406 assert_eq!(row.status, TargetStatus::Breached);
407 let s = Snapshot {
408 targets: vec![row, TargetSnapshot::new("B", vec![cell(RuleStatus::Pass)])],
409 };
410 assert_eq!(s.breaches(), 1);
411 }
412}