egui_charts/services/detectors/
alerts.rs1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
4
5use chrono::{DateTime, TimeDelta, Utc};
6
7use super::{DetectionKind, DetectorSettings, OrderFlowDetectors, RETAINED, Tracked};
8use crate::model::SignalId;
9use crate::services::{Events, Notification, Notifications, SignalEvent};
10
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub struct DetectionCounts {
14 pub total: u64,
16 pub last_five_minutes: u64,
18 pub last_thirty_seconds: u64,
20}
21
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24pub enum CountWindow {
25 Total,
27 FiveMinutes,
29 #[default]
31 ThirtySeconds,
32}
33
34impl CountWindow {
35 pub fn count(self, counts: DetectionCounts) -> u64 {
37 match self {
38 Self::Total => counts.total,
39 Self::FiveMinutes => counts.last_five_minutes,
40 Self::ThirtySeconds => counts.last_thirty_seconds,
41 }
42 }
43
44 pub fn label(self) -> &'static str {
46 match self {
47 Self::Total => "Since tracking began",
48 Self::FiveMinutes => "Last 5 minutes",
49 Self::ThirtySeconds => "Last 30 seconds",
50 }
51 }
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
56#[non_exhaustive]
57pub struct CountAlert {
58 pub window: CountWindow,
60 pub minimum: u64,
62 pub sound: bool,
64}
65
66impl Default for CountAlert {
67 fn default() -> Self {
68 Self {
69 window: CountWindow::default(),
70 minimum: 1,
71 sound: false,
72 }
73 }
74}
75
76#[derive(Clone, Debug, PartialEq)]
78#[non_exhaustive]
79pub struct DetectionAlert {
80 pub kind: DetectionKind,
82 pub notification: Notification,
84 pub count: Option<(CountWindow, u64)>,
86 pub sound: bool,
88}
89
90#[derive(Default)]
91pub(super) struct Delivery {
92 alerts: VecDeque<DetectionAlert>,
93 log: VecDeque<Notification>,
94 watchers: Vec<Events<Notification>>,
95}
96
97impl Delivery {
98 fn push(&mut self, alert: DetectionAlert) {
99 self.watchers.retain(Events::is_active);
100 for events in &self.watchers {
101 events.push(alert.notification.clone());
102 }
103 self.log.push_back(alert.notification.clone());
104 self.alerts.push_back(alert);
105 if self.log.len() > RETAINED {
106 self.log.pop_front();
107 }
108 if self.alerts.len() > RETAINED {
109 self.alerts.pop_front();
110 }
111 }
112}
113
114impl OrderFlowDetectors {
115 pub fn counts(&self, symbol: &str, kind: DetectionKind, now: DateTime<Utc>) -> DetectionCounts {
119 self.lock()
120 .symbols
121 .get(symbol)
122 .map(|tracked| tracked.counting.counts(kind, now))
123 .unwrap_or_default()
124 }
125
126 pub fn take_alerts(&self) -> Vec<DetectionAlert> {
129 self.lock().delivery.alerts.drain(..).collect()
130 }
131}
132
133impl Notifications for OrderFlowDetectors {
134 fn watch(&self, events: Events<Notification>) {
135 let mut state = self.lock();
136 for notification in &state.delivery.log {
137 events.push(notification.clone());
138 }
139 state.delivery.watchers.retain(Events::is_active);
140 state.delivery.watchers.push(events);
141 }
142}
143
144#[derive(Default)]
145pub(super) struct Counting {
146 kinds: HashMap<DetectionKind, Counter>,
147 now: Option<DateTime<Utc>>,
148}
149
150#[derive(Default)]
151struct Counter {
152 total: u64,
153 times: BTreeMap<DateTime<Utc>, u64>,
154 last_episode: u64,
155 rule: Option<(CountWindow, u64)>,
156 above: bool,
157}
158
159impl Counting {
160 fn counts(&self, kind: DetectionKind, now: DateTime<Utc>) -> DetectionCounts {
161 let Some(counter) = self.kinds.get(&kind) else {
162 return DetectionCounts::default();
163 };
164 let mut counts = DetectionCounts {
165 total: counter.total,
166 ..Default::default()
167 };
168 for (&time, &number) in &counter.times {
169 let age = now.signed_duration_since(time);
170 if age >= TimeDelta::zero() && age < TimeDelta::minutes(5) {
171 counts.last_five_minutes = counts.last_five_minutes.saturating_add(number);
172 if age < TimeDelta::seconds(30) {
173 counts.last_thirty_seconds = counts.last_thirty_seconds.saturating_add(number);
174 }
175 }
176 }
177 counts
178 }
179
180 pub(super) fn advance(
181 &mut self,
182 time: DateTime<Utc>,
183 settings: &DetectorSettings,
184 live: bool,
185 symbol: &str,
186 delivery: &mut Delivery,
187 ) {
188 let now = self.now.map_or(time, |last| last.max(time));
189 self.now = Some(now);
190 for counter in self.kinds.values_mut() {
191 while counter
192 .times
193 .first_key_value()
194 .is_some_and(|(&time, _)| now.signed_duration_since(time) >= TimeDelta::minutes(5))
195 {
196 counter.times.pop_first();
197 }
198 }
199 for kind in [DetectionKind::Iceberg, DetectionKind::StopRun] {
200 self.check(kind, now, settings, live, symbol, delivery);
201 }
202 }
203
204 fn check(
205 &mut self,
206 kind: DetectionKind,
207 now: DateTime<Utc>,
208 settings: &DetectorSettings,
209 live: bool,
210 symbol: &str,
211 delivery: &mut Delivery,
212 ) {
213 let rule = match kind {
214 DetectionKind::Iceberg => settings.iceberg_count_alert,
215 DetectionKind::StopRun => settings.stop_run_count_alert,
216 _ => return,
217 };
218 let count = rule.map(|rule| rule.window.count(self.counts(kind, now)));
219 let counter = self.kinds.entry(kind).or_default();
220 let edge = rule.map(|rule| (rule.window, rule.minimum));
221 if counter.rule != edge {
222 counter.above = false;
223 counter.rule = edge;
224 }
225 let above = rule
226 .zip(count)
227 .is_some_and(|(rule, count)| count >= rule.minimum);
228 if above && !counter.above && live {
229 let rule = rule.unwrap();
230 let count = count.unwrap();
231 let mut notification = Notification::new(now, kind.category());
232 notification.body = format!("{}: {count}", rule.window.label());
233 notification.symbol = Some(symbol.into());
234 delivery.push(DetectionAlert {
235 kind,
236 notification,
237 count: Some((rule.window, count)),
238 sound: rule.sound,
239 });
240 }
241 counter.above = above;
242 }
243}
244
245impl Tracked {
246 pub(super) fn record(
247 &mut self,
248 events: &[SignalEvent],
249 time: DateTime<Utc>,
250 live: bool,
251 settings: &DetectorSettings,
252 delivery: &mut Delivery,
253 added: &mut HashSet<SignalId>,
254 ) {
255 for event in events {
256 let SignalEvent::Upsert(signal) = event else {
257 continue;
258 };
259 if self.detections.iter().any(|kept| kept.id == signal.id)
260 || !added.insert(signal.id.clone())
261 {
262 continue;
263 }
264 let Some(kind) = DetectionKind::ALL
265 .into_iter()
266 .find(|kind| kind.category() == signal.category)
267 else {
268 continue;
269 };
270 let counter = self.counting.kinds.entry(kind).or_default();
271 if matches!(
273 kind,
274 DetectionKind::BigTrade
275 | DetectionKind::BigTradeZone
276 | DetectionKind::StopRun
277 | DetectionKind::Iceberg
278 ) {
279 let number = signal
280 .id
281 .0
282 .rsplit('-')
283 .next()
284 .unwrap()
285 .parse::<u64>()
286 .expect("numbered detector episode");
287 if number <= counter.last_episode {
288 continue;
289 }
290 counter.last_episode = number;
291 }
292 counter.total = counter.total.saturating_add(1);
293 let number = counter.times.entry(time).or_default();
294 *number = number.saturating_add(1);
295 let (alert, sound) = match kind {
296 DetectionKind::BigTrade => (settings.big_trades.alert, settings.big_trades.sound),
297 DetectionKind::BigTradeZone => (false, false),
299 DetectionKind::StopRun => (settings.stop_runs.alert, settings.stop_runs.sound),
300 DetectionKind::StopSweep => {
301 (settings.stop_sweeps.alert, settings.stop_sweeps.sound)
302 }
303 DetectionKind::Iceberg => (settings.icebergs.alert, settings.icebergs.sound),
304 DetectionKind::Absorption => (settings.absorption.alert, settings.absorption.sound),
305 _ => (settings.liquidity.alert, settings.liquidity.sound),
306 };
307 if live && alert {
308 let mut notification = Notification::new(time, kind.category());
309 notification.body = signal
310 .tooltip
311 .as_ref()
312 .or(signal.label.as_ref())
313 .cloned()
314 .unwrap_or_else(|| kind.category().into());
315 notification.symbol = Some(self.instrument.symbol.clone());
316 delivery.push(DetectionAlert {
317 kind,
318 notification,
319 count: None,
320 sound,
321 });
322 }
323 self.counting.check(
324 kind,
325 self.counting.now.unwrap_or(time),
326 settings,
327 live,
328 &self.instrument.symbol,
329 delivery,
330 );
331 }
332 }
333}