1use std::collections::HashMap;
4use std::sync::Mutex;
5
6use chrono::{DateTime, Utc};
7
8use super::{AlertEvent, Alerts, Events};
9use crate::data::{FeedError, MaybeSend, Reply};
10use crate::model::{
11 Alert, AlertChannelComparison, AlertComparison, AlertCondition, AlertFrequency, AlertId,
12 AlertSource, AlertStatus, Bar, Timeframe,
13};
14
15pub struct AlertEngine {
87 state: Mutex<State>,
88 deliver: Option<Box<dyn Deliver>>,
89}
90
91trait Deliver: MaybeSend {
93 fn deliver(&self, alert: &Alert, event: &AlertEvent);
94}
95
96impl<F: Fn(&Alert, &AlertEvent) + MaybeSend> Deliver for F {
97 fn deliver(&self, alert: &Alert, event: &AlertEvent) {
98 self(alert, event);
99 }
100}
101
102#[derive(Default)]
103struct State {
104 alerts: Vec<Alert>,
105 events: Option<Events<AlertEvent>>,
106 next_id: u64,
107 watchlists: HashMap<String, Vec<String>>,
108 runs: HashMap<(AlertId, String), Run>,
109}
110
111#[derive(Default)]
113struct Run {
114 newest: Option<DateTime<Utc>>,
116 seen: Option<Seen>,
118 fired: Option<DateTime<Utc>>,
121 done: bool,
123 #[cfg(feature = "strategies")]
124 last_fill: u64,
125}
126
127#[derive(Clone, PartialEq)]
130struct Seen {
131 time: DateTime<Utc>,
132 scheduled: bool,
133 minute: Option<DateTime<Utc>>,
134 bar: (u64, u64),
136 rules: Vec<[Option<f64>; 3]>,
137}
138
139impl Default for AlertEngine {
140 fn default() -> Self {
141 Self::new()
142 }
143}
144
145impl AlertEngine {
146 pub fn new() -> Self {
149 Self {
150 state: Mutex::new(State::default()),
151 deliver: None,
152 }
153 }
154
155 pub fn on_trigger(
160 mut self,
161 deliver: impl Fn(&Alert, &AlertEvent) + MaybeSend + 'static,
162 ) -> Self {
163 self.deliver = Some(Box::new(deliver));
164 self
165 }
166
167 pub fn with_alerts(self, alerts: Vec<Alert>) -> Self {
170 {
171 let mut state = self.lock();
172 let (kept, new): (Vec<_>, Vec<_>) = alerts.into_iter().partition(|a| a.id.is_some());
174 for alert in kept.into_iter().chain(new) {
175 state.insert(alert);
176 }
177 }
178 self
179 }
180
181 fn lock(&self) -> std::sync::MutexGuard<'_, State> {
182 self.state
183 .lock()
184 .unwrap_or_else(|poisoned| poisoned.into_inner())
185 }
186
187 pub fn alerts(&self) -> Vec<Alert> {
189 self.lock().alerts.clone()
190 }
191
192 pub fn subjects(&self, id: &AlertId) -> Vec<String> {
195 let state = self.lock();
196 let Some(alert) = state.alerts.iter().find(|alert| {
197 alert.id.as_ref() == Some(id)
198 && alert.status == AlertStatus::Active
199 && alert.is_valid()
200 && !matches!(alert.condition, AlertCondition::StrategyFills(_))
201 }) else {
202 return Vec::new();
203 };
204 let symbols = match &alert.watchlist {
205 Some(list) => state.watchlists.get(list).cloned().unwrap_or_default(),
206 None => vec![alert.symbol.clone()],
207 };
208 symbols
209 .into_iter()
210 .filter(|symbol| {
211 !state
212 .runs
213 .get(&(id.clone(), symbol.clone()))
214 .is_some_and(|run| run.done)
215 })
216 .collect()
217 }
218
219 pub fn set_watchlist(&self, id: &str, symbols: Vec<String>) {
222 let mut state = self.lock();
223 let State { alerts, runs, .. } = &mut *state;
224 runs.retain(|(alert, symbol), _| {
225 symbols.contains(symbol)
226 || !alerts
227 .iter()
228 .any(|a| a.id.as_ref() == Some(alert) && a.watchlist.as_deref() == Some(id))
229 });
230 state.watchlists.insert(id.to_owned(), symbols);
231 }
232
233 pub fn update(
239 &self,
240 symbol: &str,
241 timeframe: Timeframe,
242 bars: &[Bar],
243 now: DateTime<Utc>,
244 value: impl Fn(&AlertSource, usize) -> Option<f64>,
245 ) {
246 self.update_matching(None, symbol, timeframe, bars, now, value);
247 }
248
249 pub fn update_alert(
252 &self,
253 id: &AlertId,
254 symbol: &str,
255 timeframe: Timeframe,
256 bars: &[Bar],
257 now: DateTime<Utc>,
258 value: impl Fn(&AlertSource, usize) -> Option<f64>,
259 ) {
260 self.update_matching(Some(id), symbol, timeframe, bars, now, value);
261 }
262
263 fn update_matching(
264 &self,
265 id: Option<&AlertId>,
266 symbol: &str,
267 timeframe: Timeframe,
268 bars: &[Bar],
269 now: DateTime<Utc>,
270 value: impl Fn(&AlertSource, usize) -> Option<f64>,
271 ) {
272 let mut fired = Vec::new();
273 let mut state = self.lock();
274 state.expire(now);
275 let State {
276 alerts,
277 events,
278 watchlists,
279 runs,
280 ..
281 } = &mut *state;
282 for alert in alerts.iter_mut().filter(|alert| {
283 alert.status == AlertStatus::Active
284 && alert.is_valid()
285 && !matches!(alert.condition, AlertCondition::StrategyFills(_))
286 && id.is_none_or(|id| alert.id.as_ref() == Some(id))
287 }) {
288 let Some(id) = alert.id.clone() else { continue };
289 let subject = match &alert.watchlist {
290 Some(list) => watchlists
291 .get(list)
292 .is_some_and(|members| members.iter().any(|member| member == symbol)),
293 None => alert.symbol == symbol,
294 };
295 if !subject {
296 continue;
297 }
298 match alert.timeframe {
299 Some(own) if own != timeframe => continue,
300 Some(_) => {}
301 None => {
302 alert.timeframe = Some(timeframe);
303 if let Some(events) = events {
304 events.push(AlertEvent::Upsert(alert.clone()));
305 }
306 }
307 }
308 let run = runs.entry((id.clone(), symbol.to_owned())).or_default();
309 let Some((bar, message)) = evaluate(alert, run, (symbol, timeframe), bars, now, &value)
310 else {
311 continue;
312 };
313 let price = bar.close.is_finite().then_some(bar.close);
314 if alert.frequency == AlertFrequency::Once {
315 run.done = true;
316 if alert.watchlist.is_none() {
317 alert.status = AlertStatus::Triggered;
318 }
319 }
320 run.fired = Some(occurrence(alert.frequency, bar, now));
321 if let Some(events) = events
322 && alert.status != AlertStatus::Active
323 {
324 events.push(AlertEvent::Upsert(alert.clone()));
325 }
326 let event = AlertEvent::TriggeredForSymbol {
327 id,
328 symbol: symbol.to_owned(),
329 time: now,
330 price,
331 message,
332 };
333 if let Some(events) = events {
334 events.push(event.clone());
335 }
336 fired.push((alert.clone(), event));
337 }
338 runs.retain(|(id, _), _| {
339 alerts
340 .iter()
341 .any(|a| a.id.as_ref() == Some(id) && a.status == AlertStatus::Active)
342 });
343 drop(state);
344 if let Some(deliver) = &self.deliver {
345 for (alert, event) in &fired {
346 deliver.deliver(alert, event);
347 }
348 }
349 }
350
351 #[cfg(feature = "strategies")]
361 pub fn strategy_fills(
362 &self,
363 run: &str,
364 symbol: &str,
365 timeframe: Timeframe,
366 fills: &[crate::strategies::StrategyFill],
367 now: DateTime<Utc>,
368 ) {
369 let mut fired = Vec::new();
370 let mut state = self.lock();
371 state.expire(now);
372 let State {
373 alerts,
374 runs,
375 events,
376 watchlists,
377 ..
378 } = &mut *state;
379 for alert in alerts.iter_mut().filter(|alert| {
380 alert.status == AlertStatus::Active
381 && alert.is_valid()
382 && matches!(&alert.condition, AlertCondition::StrategyFills(id) if id == run)
383 && alert.timeframe.is_none_or(|own| own == timeframe)
384 && match &alert.watchlist {
385 Some(list) => watchlists
386 .get(list)
387 .is_some_and(|members| members.iter().any(|member| member == symbol)),
388 None => alert.symbol == symbol,
389 }
390 }) {
391 let Some(id) = alert.id.clone() else { continue };
392 if alert.timeframe.is_none() {
393 alert.timeframe = Some(timeframe);
394 if let Some(events) = events {
395 events.push(AlertEvent::Upsert(alert.clone()));
396 }
397 }
398 let seen = runs.entry((id.clone(), symbol.to_owned())).or_default();
399 for fill in fills {
400 if fill.sequence <= seen.last_fill
401 || !fill.price.is_finite()
402 || !fill.quantity.is_finite()
403 || fill.quantity <= 0.0
404 || !fill.position.is_finite()
405 {
406 continue;
407 }
408 seen.last_fill = fill.sequence;
409 if seen.done
410 || alert
411 .schedule
412 .as_ref()
413 .is_some_and(|schedule| !schedule.allows(now))
414 {
415 continue;
416 }
417 if alert.frequency == AlertFrequency::Once {
418 seen.done = true;
419 if alert.watchlist.is_none() {
420 alert.status = AlertStatus::Triggered;
421 }
422 if let Some(events) = events {
423 events.push(AlertEvent::Upsert(alert.clone()));
424 }
425 }
426 let side = match fill.side {
427 crate::model::OrderSide::Buy => "Buy",
428 crate::model::OrderSide::Sell => "Sell",
429 };
430 let mut price = fill.price.to_string();
431 match price.split_once('.') {
432 None => price.push_str(".00"),
433 Some((_, fraction)) if fraction.len() == 1 => price.push('0'),
434 _ => {}
435 }
436 let message = if alert.message.is_empty() {
437 format!(
438 "{side} {} at {price}, position {}",
439 fill.quantity, fill.position
440 )
441 } else {
442 let bar = Bar::new(
443 fill.time, fill.price, fill.price, fill.price, fill.price, 0.0,
444 );
445 alert.expand_message(symbol, timeframe, &bar, |_| Some(fill.price))
446 };
447 let event = AlertEvent::TriggeredForSymbol {
448 id: id.clone(),
449 symbol: symbol.to_owned(),
450 time: fill.time,
451 price: Some(fill.price),
452 message,
453 };
454 if let Some(events) = events {
455 events.push(event.clone());
456 }
457 fired.push((alert.clone(), event));
458 }
459 }
460 drop(state);
461 if let Some(deliver) = &self.deliver {
462 for (alert, event) in &fired {
463 deliver.deliver(alert, event);
464 }
465 }
466 }
467
468 pub fn tick(&self, now: DateTime<Utc>) {
471 self.lock().expire(now);
472 }
473}
474
475impl State {
476 fn insert(&mut self, mut alert: Alert) -> Alert {
478 if alert.id.is_none() {
479 loop {
480 self.next_id += 1;
481 let id = AlertId(self.next_id.to_string());
482 if !self.alerts.iter().any(|a| a.id.as_ref() == Some(&id)) {
483 alert.id = Some(id);
484 break;
485 }
486 }
487 }
488 let same_strategy = matches!(alert.condition, AlertCondition::StrategyFills(_))
489 && self
490 .alerts
491 .iter()
492 .any(|old| old.id == alert.id && old.condition == alert.condition);
493 self.runs.retain(|(id, _), run| {
494 if Some(id) != alert.id.as_ref() {
495 return true;
496 }
497 if same_strategy {
498 run.done = false;
500 }
501 same_strategy
502 });
503 match self.alerts.iter_mut().find(|a| a.id == alert.id) {
504 Some(kept) => *kept = alert.clone(),
505 None => self.alerts.push(alert.clone()),
506 }
507 alert
508 }
509
510 fn push(&self, event: AlertEvent) {
511 if let Some(events) = &self.events {
512 events.push(event);
513 }
514 }
515
516 fn expire(&mut self, now: DateTime<Utc>) {
517 let mut expired = Vec::new();
518 for alert in self.alerts.iter_mut().filter(|alert| {
519 alert.status == AlertStatus::Active && alert.expires.is_some_and(|at| at <= now)
520 }) {
521 alert.status = AlertStatus::Expired;
522 expired.push(alert.clone());
523 }
524 for alert in expired {
525 if !matches!(alert.condition, AlertCondition::StrategyFills(_)) {
526 self.runs.retain(|(id, _), _| Some(id) != alert.id.as_ref());
527 }
528 self.push(AlertEvent::Upsert(alert));
529 }
530 }
531}
532
533impl Alerts for AlertEngine {
534 fn engine(&self) -> Option<&AlertEngine> {
535 Some(self)
536 }
537
538 fn watch(&self, events: Events<AlertEvent>) {
539 let mut state = self.lock();
540 for alert in &state.alerts {
541 events.push(AlertEvent::Upsert(alert.clone()));
542 }
543 state.events = Some(events);
544 }
545
546 fn save(&self, alert: Alert, reply: Reply<()>) {
547 if !alert.is_valid() {
548 reply.send(Err(FeedError::InvalidData(
549 "Alert contains invalid conditions".into(),
550 )));
551 return;
552 }
553 let mut state = self.lock();
554 let alert = state.insert(alert);
555 state.push(AlertEvent::Upsert(alert));
556 reply.send(Ok(()));
557 }
558
559 fn remove(&self, id: AlertId, reply: Reply<()>) {
560 let mut state = self.lock();
561 state.runs.retain(|(kept, _), _| *kept != id);
562 state.alerts.retain(|a| a.id.as_ref() != Some(&id));
563 state.push(AlertEvent::Remove(id));
564 reply.send(Ok(()));
565 }
566}
567
568fn evaluate<'a>(
571 alert: &Alert,
572 run: &mut Run,
573 (symbol, timeframe): (&str, Timeframe),
574 bars: &'a [Bar],
575 now: DateTime<Utc>,
576 value: &impl Fn(&AlertSource, usize) -> Option<f64>,
577) -> Option<(&'a Bar, String)> {
578 let closed = alert.frequency == AlertFrequency::OncePerBarClose;
579 let index = bars.len().checked_sub(if closed { 2 } else { 1 })?;
580 let bar = &bars[index];
581 if run.newest.is_some_and(|newest| bar.time < newest)
582 || (closed && run.newest == Some(bar.time))
583 {
584 return None;
585 }
586 let source = |source: &AlertSource, at: usize| -> Option<f64> {
587 let found = match source {
588 AlertSource::Price => bars.get(at)?.close,
589 AlertSource::Volume => bars.get(at)?.volume,
590 _ => value(source, at)?,
591 };
592 found.is_finite().then_some(found)
593 };
594 let rules: Vec<_> = std::iter::once((&alert.source, &alert.condition))
595 .chain(
596 alert
597 .additional_conditions
598 .iter()
599 .map(|rule| (&rule.source, &rule.condition)),
600 )
601 .collect();
602 let seen = Seen {
603 time: bar.time,
604 scheduled: alert
605 .schedule
606 .as_ref()
607 .is_none_or(|schedule| schedule.allows(now)),
608 minute: (alert.frequency == AlertFrequency::OncePerMinute)
609 .then(|| occurrence(alert.frequency, bar, now)),
610 bar: (bar.close.to_bits(), bar.volume.to_bits()),
611 rules: rules
612 .iter()
613 .map(|(src, condition)| {
614 let [a, b] = condition
615 .partners()
616 .map(|other| other.and_then(|other| source(other, index)));
617 [source(src, index), a, b]
618 })
619 .collect(),
620 };
621 if run.seen.as_ref() == Some(&seen) {
622 return None;
623 }
624 let holds = seen.scheduled
625 && rules.iter().enumerate().all(|(n, (src, condition))| {
626 let [current, a, b] = seen.rules[n];
627 let previous = match &run.seen {
628 Some(last)
629 if last.time == bar.time
630 || !matches!(condition, AlertCondition::Channel { .. }) =>
631 {
632 last.rules[n]
633 }
634 _ => {
635 let previous = |src| index.checked_sub(1).and_then(|at| source(src, at));
636 let [a, b] = condition.partners().map(|other| other.and_then(previous));
637 [previous(src), a, b]
638 }
639 };
640 current.is_some_and(|current| {
641 condition_holds(
642 condition,
643 current,
644 previous,
645 [a, b],
646 &bars[..=index],
647 |back| {
648 index
649 .checked_sub(back as usize)
650 .and_then(|at| source(src, at))
651 },
652 )
653 })
654 });
655 run.newest = Some(bar.time);
656 run.seen = Some(seen);
657 let fires = holds
658 && match alert.frequency {
659 AlertFrequency::Once => !run.done,
660 AlertFrequency::EveryTime => true,
661 AlertFrequency::OncePerMinute => run
662 .fired
663 .is_none_or(|last| occurrence(alert.frequency, bar, now) > last),
664 AlertFrequency::OncePerBar | AlertFrequency::OncePerBarClose => {
665 run.fired != Some(occurrence(alert.frequency, bar, now))
666 }
667 };
668 fires.then(|| {
669 let message = alert.expand_message(symbol, timeframe, bar, |src| source(src, index));
670 (bar, message)
671 })
672}
673
674fn occurrence(frequency: AlertFrequency, bar: &Bar, now: DateTime<Utc>) -> DateTime<Utc> {
676 match frequency {
677 AlertFrequency::OncePerMinute => {
678 DateTime::from_timestamp(now.timestamp().div_euclid(60) * 60, 0).unwrap_or(now)
679 }
680 _ => bar.time,
681 }
682}
683
684fn condition_holds(
688 condition: &AlertCondition,
689 value: f64,
690 previous: [Option<f64>; 3],
691 other: [Option<f64>; 2],
692 bars: &[Bar],
693 lookback: impl Fn(u32) -> Option<f64>,
694) -> bool {
695 let [previous, previous_other, previous_second] = previous;
696 match condition {
697 AlertCondition::StrategyFills(_) => false,
698 AlertCondition::Crossing(level) => compare(
699 AlertComparison::Crossing,
700 value,
701 previous,
702 *level,
703 Some(*level),
704 ),
705 AlertCondition::CrossingUp(level) => compare(
706 AlertComparison::CrossingUp,
707 value,
708 previous,
709 *level,
710 Some(*level),
711 ),
712 AlertCondition::CrossingDown(level) => compare(
713 AlertComparison::CrossingDown,
714 value,
715 previous,
716 *level,
717 Some(*level),
718 ),
719 AlertCondition::Above(level) => value > *level,
720 AlertCondition::Below(level) => value < *level,
721 AlertCondition::EnteringChannel { lower, upper } => previous
722 .is_some_and(|p| (p < *lower || p > *upper) && value >= *lower && value <= *upper),
723 AlertCondition::ExitingChannel { lower, upper } => previous
724 .is_some_and(|p| p >= *lower && p <= *upper && (value < *lower || value > *upper)),
725 AlertCondition::InsideChannel { lower, upper } => value >= *lower && value <= *upper,
726 AlertCondition::OutsideChannel { lower, upper } => value < *lower || value > *upper,
727 AlertCondition::MovingBy {
728 value: change,
729 bars: back,
730 } => lookback(*back).is_some_and(|p| reaches(value - p, *change)),
731 AlertCondition::MovingByPercent {
732 percent,
733 bars: back,
734 } => lookback(*back)
735 .is_some_and(|p| p != 0.0 && reaches(100.0 * (value - p) / p.abs(), *percent)),
736 AlertCondition::Compare { comparison, .. } => other[0]
737 .is_some_and(|level| compare(*comparison, value, previous, level, previous_other)),
738 AlertCondition::Channel { comparison, .. } => {
739 let inside = |value: f64, a: f64, b: f64| {
740 (a != b).then_some(value >= a.min(b) && value <= a.max(b))
741 };
742 let Some(current) = other[0]
743 .zip(other[1])
744 .and_then(|(a, b)| inside(value, a, b))
745 else {
746 return false;
747 };
748 let previous = previous
749 .zip(previous_other)
750 .zip(previous_second)
751 .and_then(|((value, a), b)| inside(value, a, b));
752 match comparison {
753 AlertChannelComparison::Entering => previous == Some(false) && current,
754 AlertChannelComparison::Exiting => previous == Some(true) && !current,
755 AlertChannelComparison::Inside => current,
756 AlertChannelComparison::Outside => !current,
757 }
758 }
759 AlertCondition::VolumeAboveAverage {
760 multiplier,
761 bars: count,
762 } => {
763 let n = *count as usize;
764 if n == 0 || bars.len() <= n {
765 return false;
766 }
767 let mean = bars[bars.len() - n - 1..bars.len() - 1]
768 .iter()
769 .map(|b| b.volume)
770 .sum::<f64>()
771 / n as f64;
772 mean.is_finite() && bars[bars.len() - 1].volume > multiplier * mean
773 }
774 }
775}
776
777fn compare(
778 comparison: AlertComparison,
779 value: f64,
780 previous: Option<f64>,
781 level: f64,
782 previous_level: Option<f64>,
783) -> bool {
784 let sides = previous.zip(previous_level);
785 let up = sides.is_some_and(|(p, l)| p < l && value >= level);
786 let down = sides.is_some_and(|(p, l)| p > l && value <= level);
787 match comparison {
788 AlertComparison::Crossing => up || down,
789 AlertComparison::CrossingUp => up,
790 AlertComparison::CrossingDown => down,
791 AlertComparison::Above => value > level,
792 AlertComparison::Below => value < level,
793 }
794}
795
796fn reaches(change: f64, target: f64) -> bool {
798 if target > 0.0 {
799 change >= target
800 } else {
801 change <= target
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808 use crate::data::inbox::Inbox;
809 use crate::services::events::Listener;
810 use std::sync::Arc;
811
812 fn time(seconds: i64) -> DateTime<Utc> {
813 DateTime::from_timestamp(seconds, 0).unwrap()
814 }
815
816 fn bar(minute: i64, close: f64, volume: f64) -> Bar {
818 Bar::new(time(minute * 60), close, close, close, close, volume)
819 }
820
821 fn alert(condition: AlertCondition, frequency: AlertFrequency) -> Alert {
822 let mut alert = Alert::new("SYM", condition);
823 alert.frequency = frequency;
824 alert.timeframe = Some(Timeframe::Min1);
825 alert
826 }
827
828 struct Host {
830 engine: AlertEngine,
831 inbox: Arc<Inbox>,
832 _listener: Listener,
833 delivered: Arc<std::sync::Mutex<Vec<(Alert, AlertEvent)>>>,
834 }
835
836 impl Host {
837 fn new(alerts: Vec<Alert>) -> Self {
838 let inbox = Arc::new(Inbox::default());
839 let (_listener, events) = Listener::new(&inbox, 1);
840 let delivered = Arc::new(std::sync::Mutex::new(Vec::new()));
841 let sink = Arc::clone(&delivered);
842 let engine = AlertEngine::new()
843 .with_alerts(alerts)
844 .on_trigger(move |alert, event| {
845 sink.lock().unwrap().push((alert.clone(), event.clone()));
846 });
847 engine.watch(events);
848 Self {
849 engine,
850 inbox,
851 _listener,
852 delivered,
853 }
854 }
855
856 fn events(&self) -> Vec<AlertEvent> {
857 self.inbox
858 .drain()
859 .answers
860 .into_iter()
861 .filter(|(key, _)| *key == 1)
862 .map(|(_, event)| *event.downcast::<AlertEvent>().unwrap())
863 .collect()
864 }
865
866 fn fired(&self) -> Vec<f64> {
868 self.events()
869 .into_iter()
870 .filter_map(|event| match event {
871 AlertEvent::TriggeredForSymbol { price, .. } => price,
872 _ => None,
873 })
874 .collect()
875 }
876
877 fn save(&self, alert: Alert) -> Result<(), FeedError> {
878 self.engine.save(alert, self.inbox.reply(0));
879 let answers = self.inbox.drain().answers;
880 let reply = answers.into_iter().find(|(key, _)| *key == 0).unwrap().1;
881 *reply.downcast::<Result<(), FeedError>>().unwrap()
882 }
883 }
884
885 fn feed(host: &Host, bars: &[Bar], study: &[f64], now: i64) {
887 host.engine
888 .update("SYM", Timeframe::Min1, bars, time(now), |_, at| {
889 study.get(at).copied()
890 });
891 }
892
893 type Tick = (f64, f64, f64);
896 type Row = (AlertCondition, Vec<Tick>, Vec<Tick>, Vec<usize>);
897
898 #[test]
899 fn crossings_follow_the_previous_value_tick_to_tick() {
900 let study = AlertSource::Study {
901 id: "s".into(),
902 output: 0,
903 };
904 let n = f64::NAN;
907 let rows: Vec<Row> = vec![
908 (
910 AlertCondition::CrossingUp(100.0),
911 vec![(99.0, 10.0, n)],
912 [99.5, 100.0, 100.0, 101.0, 99.0, 100.5]
913 .map(|c| (c, 10.0, n))
914 .to_vec(),
915 vec![1, 5],
916 ),
917 (
918 AlertCondition::CrossingDown(100.0),
919 vec![(101.0, 10.0, n)],
920 [100.0, 100.0, 99.0, 101.0, 100.0]
921 .map(|c| (c, 10.0, n))
922 .to_vec(),
923 vec![0, 4],
924 ),
925 (
926 AlertCondition::Crossing(100.0),
927 vec![(99.0, 10.0, n)],
928 [101.0, 99.0, 99.5, 100.0].map(|c| (c, 10.0, n)).to_vec(),
929 vec![0, 1, 3],
930 ),
931 (
934 AlertCondition::CrossingUp(100.0),
935 vec![(99.0, 10.0, n)],
936 [n, 101.0, 99.0, 101.0].map(|c| (c, 10.0, n)).to_vec(),
937 vec![3],
938 ),
939 (
940 AlertCondition::Above(100.0),
941 vec![(99.0, 10.0, n)],
942 [n, 101.0, 100.0].map(|c| (c, 10.0, n)).to_vec(),
943 vec![1],
944 ),
945 (
947 AlertCondition::Above(100.0),
948 vec![(99.0, n, n)],
949 [101.0, 101.0].map(|c| (c, n, n)).to_vec(),
950 vec![0],
951 ),
952 (
954 AlertCondition::Compare {
955 other: study.clone(),
956 comparison: AlertComparison::CrossingUp,
957 },
958 vec![(99.0, 10.0, 100.0)],
959 vec![
960 (101.0, 10.0, 100.0),
961 (101.0, 10.0, 102.0),
962 (103.0, 10.0, 102.0),
963 ],
964 vec![0, 2],
965 ),
966 (
967 AlertCondition::EnteringChannel {
968 lower: 100.0,
969 upper: 110.0,
970 },
971 vec![(95.0, 10.0, n)],
972 [100.0, 105.0, 111.0, 110.0].map(|c| (c, 10.0, n)).to_vec(),
973 vec![0, 3],
974 ),
975 (
976 AlertCondition::ExitingChannel {
977 lower: 100.0,
978 upper: 110.0,
979 },
980 vec![(105.0, 10.0, n)],
981 [110.0, 111.0, 105.0, 99.0].map(|c| (c, 10.0, n)).to_vec(),
982 vec![1, 3],
983 ),
984 (
985 AlertCondition::InsideChannel {
986 lower: 100.0,
987 upper: 110.0,
988 },
989 vec![],
990 [100.0, 105.0, 110.0, 111.0, 99.0]
991 .map(|c| (c, 10.0, n))
992 .to_vec(),
993 vec![0, 1, 2],
994 ),
995 (
996 AlertCondition::OutsideChannel {
997 lower: 100.0,
998 upper: 110.0,
999 },
1000 vec![],
1001 [99.0, 100.0, 105.0, 110.0, 111.0]
1002 .map(|c| (c, 10.0, n))
1003 .to_vec(),
1004 vec![0, 4],
1005 ),
1006 (
1008 AlertCondition::MovingBy {
1009 value: 10.0,
1010 bars: 2,
1011 },
1012 vec![(100.0, 10.0, n), (100.0, 10.0, n), (100.0, 10.0, n)],
1013 [105.0, 110.0, 111.0].map(|c| (c, 10.0, n)).to_vec(),
1014 vec![1, 2],
1015 ),
1016 (
1017 AlertCondition::MovingBy {
1018 value: -10.0,
1019 bars: 1,
1020 },
1021 vec![(100.0, 10.0, n)],
1022 [95.0, 90.0].map(|c| (c, 10.0, n)).to_vec(),
1023 vec![1],
1024 ),
1025 (
1026 AlertCondition::MovingByPercent {
1027 percent: 10.0,
1028 bars: 1,
1029 },
1030 vec![(100.0, 10.0, n)],
1031 [109.0, 110.0].map(|c| (c, 10.0, n)).to_vec(),
1032 vec![1],
1033 ),
1034 (
1035 AlertCondition::MovingByPercent {
1036 percent: -10.0,
1037 bars: 1,
1038 },
1039 vec![(-100.0, 10.0, n)],
1040 [-109.0, -110.0].map(|c| (c, 10.0, n)).to_vec(),
1041 vec![1],
1042 ),
1043 (
1044 AlertCondition::MovingByPercent {
1045 percent: 10.0,
1046 bars: 1,
1047 },
1048 vec![(0.0, 10.0, n)],
1049 [1.0, 5.0].map(|c| (c, 10.0, n)).to_vec(),
1050 vec![],
1051 ),
1052 (
1054 AlertCondition::VolumeAboveAverage {
1055 multiplier: 2.0,
1056 bars: 3,
1057 },
1058 vec![(100.0, 10.0, n), (100.0, 20.0, n), (100.0, 30.0, n)],
1059 [50.0, 40.0, 41.0].map(|v| (100.0, v, n)).to_vec(),
1060 vec![0, 2],
1061 ),
1062 ];
1063 for (row, (condition, history, ticks, expected)) in rows.into_iter().enumerate() {
1064 let host = Host::new(vec![alert(condition, AlertFrequency::EveryTime)]);
1065 host.events();
1066 let mut fired = Vec::new();
1067 for (tick, current) in ticks.iter().enumerate() {
1068 let bars: Vec<Bar> = history
1069 .iter()
1070 .chain([current])
1071 .enumerate()
1072 .map(|(minute, (close, volume, _))| bar(minute as i64, *close, *volume))
1073 .collect();
1074 let values: Vec<f64> = history.iter().chain([current]).map(|t| t.2).collect();
1075 feed(&host, &bars, &values, 1_000 + tick as i64);
1076 if !host.fired().is_empty() {
1077 fired.push(tick);
1078 }
1079 }
1080 assert_eq!(fired, expected, "row {row}");
1081 }
1082 }
1083
1084 #[test]
1085 fn frequencies_count_ticks_bars_and_closed_bars() {
1086 let frames = [
1089 vec![bar(0, 99.0, 1.0), bar(1, 101.0, 1.0)],
1090 vec![bar(0, 99.0, 1.0), bar(1, 102.0, 1.0)],
1091 vec![bar(0, 99.0, 1.0), bar(1, 102.0, 1.0), bar(2, 103.0, 1.0)],
1092 vec![bar(0, 99.0, 1.0), bar(1, 102.0, 1.0), bar(2, 104.0, 1.0)],
1093 ];
1094 for (frequency, expected, status) in [
1095 (AlertFrequency::Once, vec![101.0], AlertStatus::Triggered),
1096 (
1097 AlertFrequency::EveryTime,
1098 vec![101.0, 102.0, 103.0, 104.0],
1099 AlertStatus::Active,
1100 ),
1101 (
1102 AlertFrequency::OncePerBar,
1103 vec![101.0, 103.0],
1104 AlertStatus::Active,
1105 ),
1106 (
1108 AlertFrequency::OncePerBarClose,
1109 vec![102.0],
1110 AlertStatus::Active,
1111 ),
1112 ] {
1113 let host = Host::new(vec![alert(AlertCondition::Above(100.0), frequency)]);
1114 host.events();
1115 let mut fired = Vec::new();
1116 for (n, frame) in frames.iter().enumerate() {
1117 feed(&host, frame, &[], 1_000 + n as i64);
1118 fired.extend(host.fired());
1119 }
1120 assert_eq!(fired, expected, "{frequency}");
1121 assert_eq!(host.engine.alerts()[0].status, status, "{frequency}");
1122 }
1123 }
1124
1125 #[test]
1126 fn schedules_gate_channel_states_without_consuming_occurrences() {
1127 use crate::model::{AlertRule, NotificationSchedule, NotificationWindow, TimezoneId};
1128 use chrono::{NaiveTime, Weekday};
1129
1130 for condition in [
1131 AlertCondition::InsideChannel {
1132 lower: 100.0,
1133 upper: 110.0,
1134 },
1135 AlertCondition::OutsideChannel {
1136 lower: 90.0,
1137 upper: 99.0,
1138 },
1139 ] {
1140 for frequency in [
1141 AlertFrequency::Once,
1142 AlertFrequency::EveryTime,
1143 AlertFrequency::OncePerBar,
1144 AlertFrequency::OncePerBarClose,
1145 AlertFrequency::OncePerMinute,
1146 ] {
1147 let mut watching = alert(condition.clone(), frequency);
1148 watching.additional_conditions.push(AlertRule {
1149 source: AlertSource::Volume,
1150 condition: AlertCondition::Above(5.0),
1151 });
1152 watching.schedule = Some(Box::new(NotificationSchedule {
1153 timezone: TimezoneId::utc(),
1154 days: vec![Weekday::Thu],
1155 windows: vec![NotificationWindow {
1156 start: NaiveTime::from_hms_opt(0, 1, 0).unwrap(),
1157 end: NaiveTime::from_hms_opt(0, 2, 0).unwrap(),
1158 }],
1159 }));
1160 let host = Host::new(vec![watching]);
1161 host.events();
1162 let bars = [bar(-2, 100.0, 10.0), bar(-1, 100.0, 10.0)];
1163 feed(&host, &bars, &[], 59);
1164 assert!(host.events().is_empty(), "{condition:?}, {frequency}");
1165 assert_eq!(host.engine.alerts()[0].status, AlertStatus::Active);
1166 assert!(host.delivered.lock().unwrap().is_empty());
1167
1168 let next = if frequency == AlertFrequency::OncePerBarClose {
1170 vec![bars[0].clone(), bars[1].clone(), bar(0, 100.0, 10.0)]
1171 } else {
1172 bars.to_vec()
1173 };
1174 feed(&host, &next, &[], 60);
1175 assert_eq!(host.fired(), [100.0], "{condition:?}, {frequency}");
1176 feed(&host, &next, &[], 61);
1177 assert!(host.fired().is_empty());
1178 assert_eq!(host.delivered.lock().unwrap().len(), 1);
1179
1180 let closed = [bar(0, 101.0, 10.0), bar(1, 102.0, 10.0)];
1181 feed(&host, &closed, &[], 120);
1182 assert!(host.fired().is_empty(), "the end of a window is excluded");
1183 }
1184 }
1185 }
1186
1187 #[test]
1188 fn minute_frequency_uses_clock_minutes_per_watchlist_member() {
1189 let mut watching = alert(
1190 AlertCondition::InsideChannel {
1191 lower: 100.0,
1192 upper: 110.0,
1193 },
1194 AlertFrequency::OncePerMinute,
1195 );
1196 watching.watchlist = Some("list".into());
1197 let host = Host::new(vec![watching]);
1198 host.engine
1199 .set_watchlist("list", vec!["SYM".into(), "OTHER".into()]);
1200 host.events();
1201 for (now, count) in [
1204 (-61, 2),
1205 (-60, 2),
1206 (-1, 0),
1207 (0, 2),
1208 (59, 0),
1209 (60, 2),
1210 (0, 0),
1212 (60, 0),
1213 (120, 2),
1214 ] {
1215 for symbol in ["SYM", "OTHER"] {
1216 host.engine.update(
1217 symbol,
1218 Timeframe::Min1,
1219 &[bar(-5, 100.0, 1.0)],
1220 time(now),
1221 |_, _| None,
1222 );
1223 }
1224 assert_eq!(host.fired().len(), count, "second {now}");
1225 }
1226 }
1227
1228 #[test]
1229 fn a_trigger_follows_the_change_of_status_and_reloads_never_repeat_it() {
1230 let mut watching = alert(AlertCondition::Above(100.0), AlertFrequency::EveryTime);
1231 watching.timeframe = None;
1232 watching.message = "{{symbol}} closed at {{close}}".into();
1233 let host = Host::new(vec![watching]);
1234 host.events();
1235 let bars = [bar(0, 99.0, 1.0), bar(1, 101.0, 1.0), bar(2, 103.0, 1.0)];
1236 feed(&host, &bars, &[], 1_000);
1237 let events = host.events();
1238 assert!(
1240 matches!(&events[0], AlertEvent::Upsert(a) if a.timeframe == Some(Timeframe::Min1))
1241 );
1242 assert!(
1243 matches!(&events[1], AlertEvent::TriggeredForSymbol { price: Some(p), .. } if *p == 103.0)
1244 );
1245 assert_eq!(events.len(), 2);
1246 assert!(
1248 matches!(&events[1], AlertEvent::TriggeredForSymbol { message, .. } if message == "SYM closed at 103")
1249 );
1250 let delivered = host.delivered.lock().unwrap().clone();
1251 assert_eq!(delivered.len(), 1);
1252 assert_eq!(delivered[0].1, events[1]);
1253 feed(&host, &bars, &[], 1_001);
1254 let mut reloaded = vec![bar(-1, 98.0, 1.0)];
1256 reloaded.extend(bars.clone());
1257 feed(&host, &reloaded, &[], 1_002);
1258 feed(&host, &bars[..2], &[], 1_003);
1259 host.engine
1260 .update("SYM", Timeframe::Min5, &bars, time(1_004), |_, _| None);
1261 assert!(host.events().is_empty());
1262 }
1263
1264 #[cfg(all(feature = "ui", not(target_arch = "wasm32")))]
1265 #[test]
1266 fn dropping_a_feed_under_the_context_lock_does_not_wake_cancelled_replies() {
1267 use crate::services::AlertFeed;
1268 #[derive(Default)]
1269 struct Feed(Mutex<Option<Reply<crate::data::HistoryPage>>>);
1270 impl crate::data::Datafeed for Feed {
1271 fn history(
1272 &self,
1273 _: crate::data::HistoryRequest,
1274 reply: Reply<crate::data::HistoryPage>,
1275 ) {
1276 *self.0.lock().unwrap() = Some(reply);
1277 }
1278 fn unsubscribe(&self, _: crate::data::SubscriptionId) {
1279 drop(self.0.lock().unwrap().take());
1280 }
1281 }
1282 let (done, finished) = std::sync::mpsc::channel();
1283 let worker = std::thread::spawn(move || {
1284 let ctx = egui::Context::default();
1285 let host = Arc::new(Feed::default());
1286 let mut feed = AlertFeed::new(
1287 host.clone(),
1288 AlertFeed::history_request("SYM", Timeframe::Min1),
1289 None,
1290 );
1291 assert!(feed.poll(&ctx, time(1000)).is_empty());
1292 assert!(host.0.lock().unwrap().is_some());
1293 ctx.input(|_| drop(feed));
1294 done.send(()).unwrap();
1295 });
1296 finished
1297 .recv_timeout(std::time::Duration::from_secs(5))
1298 .expect("dropping cancelled replies must not wait for the context lock");
1299 worker.join().unwrap();
1300 }
1301
1302 #[test]
1303 fn expired_paused_and_watchlist_alerts() {
1304 let bars = [bar(0, 99.0, 1.0), bar(1, 101.0, 1.0)];
1305 let mut expiring = alert(AlertCondition::Above(100.0), AlertFrequency::EveryTime);
1307 expiring.expires = Some(time(2_000));
1308 let host = Host::new(vec![expiring]);
1309 host.events();
1310 host.engine.tick(time(2_000));
1311 assert!(
1312 matches!(&host.events()[..], [AlertEvent::Upsert(a)] if a.status == AlertStatus::Expired)
1313 );
1314 feed(&host, &bars, &[], 2_001);
1315 assert!(host.events().is_empty());
1316
1317 let mut paused = alert(AlertCondition::CrossingUp(100.0), AlertFrequency::Once);
1319 paused.status = AlertStatus::Disabled;
1320 let host = Host::new(vec![paused.clone()]);
1321 host.events();
1322 feed(&host, &bars, &[], 1_000);
1323 assert!(host.events().is_empty());
1324 paused.id = host.engine.alerts()[0].id.clone();
1325 paused.status = AlertStatus::Active;
1326 host.save(paused).unwrap();
1327 host.events();
1328 feed(&host, &bars, &[], 1_001);
1329 let events = host.events();
1331 assert!(matches!(&events[0], AlertEvent::Upsert(a) if a.status == AlertStatus::Triggered));
1332 assert!(
1333 matches!(&events[1], AlertEvent::TriggeredForSymbol { symbol, time: t, price: Some(p), .. }
1334 if symbol == "SYM" && *t == time(1_001) && *p == 101.0)
1335 );
1336
1337 let mut list = alert(AlertCondition::Above(100.0), AlertFrequency::Once);
1339 list.symbol = String::new();
1340 list.watchlist = Some("tech".into());
1341 let host = Host::new(vec![list]);
1342 host.engine
1343 .set_watchlist("tech", vec!["SYM".into(), "OTHER".into()]);
1344 host.events();
1345 for symbol in ["SYM", "SYM", "OTHER", "NOT_A_MEMBER"] {
1346 host.engine
1347 .update(symbol, Timeframe::Min1, &bars, time(1_000), |_, _| None);
1348 }
1349 let symbols: Vec<_> = host
1350 .events()
1351 .into_iter()
1352 .filter_map(|event| match event {
1353 AlertEvent::TriggeredForSymbol { symbol, .. } => Some(symbol),
1354 _ => None,
1355 })
1356 .collect();
1357 assert_eq!(symbols, ["SYM", "OTHER"]);
1358 assert_eq!(host.engine.alerts()[0].status, AlertStatus::Active);
1359
1360 assert!(matches!(
1362 host.save(Alert::new("SYM", AlertCondition::Above(f64::NAN))),
1363 Err(FeedError::InvalidData(_))
1364 ));
1365
1366 let mut kept = Alert::new("B", AlertCondition::Above(1.0));
1368 kept.id = Some(AlertId("1".into()));
1369 let restored =
1370 AlertEngine::new().with_alerts(vec![Alert::new("A", AlertCondition::Above(1.0)), kept]);
1371 assert_eq!(restored.alerts().len(), 2);
1372 }
1373
1374 #[test]
1375 fn moving_channels_resolve_both_boundaries_and_forget_missing_values() {
1376 let rising = |source: &AlertSource, at: usize| {
1378 Some(
1379 90.0 + at as f64 * 10.0
1380 + if *source == AlertSource::Drawing("d#1".into()) {
1381 10.0
1382 } else {
1383 0.0
1384 },
1385 )
1386 };
1387 for (comparison, expected) in [
1388 ("Entering", vec![1]),
1389 ("Exiting", vec![2]),
1390 ("Inside", vec![1]),
1391 ("Outside", vec![0, 2]),
1392 ] {
1393 let condition: AlertCondition = serde_json::from_value(serde_json::json!({
1394 "Channel": {"boundaries": [{"Drawing":"d"}, {"Drawing":"d#1"}], "comparison": comparison}
1395 })).unwrap();
1396 let watching = alert(condition, AlertFrequency::EveryTime);
1397 assert_eq!(watching.sources().count(), 3);
1398 let restored: Alert =
1399 serde_json::from_str(&serde_json::to_string(&watching).unwrap()).unwrap();
1400 let host = Host::new(vec![restored]);
1401 host.events();
1402 let bars: Vec<_> = (0..3).map(|i| bar(i, 105.0, 1.0)).collect();
1403 let mut fired = Vec::new();
1404 for i in 0..3 {
1405 host.engine.update(
1406 "SYM",
1407 Timeframe::Min1,
1408 &bars[..=i],
1409 time(1000 + i as i64),
1410 rising,
1411 );
1412 if !host.fired().is_empty() {
1413 fired.push(i);
1414 }
1415 }
1416 assert_eq!(fired, expected, "{comparison}");
1417 for (bounds, fires) in [
1419 (
1420 [Some(100.0), Some(110.0)],
1421 comparison == "Entering" || comparison == "Inside",
1422 ),
1423 ([Some(100.0), None], false),
1424 ([Some(200.0), Some(210.0)], comparison == "Outside"),
1425 ([None, None], false),
1426 ([Some(f64::NAN), Some(210.0)], false),
1427 ([Some(105.0), Some(105.0)], false),
1428 ([Some(110.0), Some(100.0)], comparison == "Inside"),
1429 ] {
1430 host.engine
1431 .update("SYM", Timeframe::Min1, &bars, time(1010), |source, _| {
1432 bounds[usize::from(*source == AlertSource::Drawing("d#1".into()))]
1433 });
1434 assert_eq!(!host.fired().is_empty(), fires, "{comparison} {bounds:?}");
1435 }
1436 for (last, current) in [(0, 2), (1, 2)] {
1438 let host = Host::new(vec![watching.clone()]);
1439 host.events();
1440 host.engine.update(
1441 "SYM",
1442 Timeframe::Min1,
1443 &bars[..=last],
1444 time(1020),
1445 |source, _| {
1446 Some(if *source == AlertSource::Drawing("d#1".into()) {
1447 210.0
1448 } else {
1449 200.0
1450 })
1451 },
1452 );
1453 host.fired();
1454 host.engine.update(
1455 "SYM",
1456 Timeframe::Min1,
1457 &bars[..=current],
1458 time(1030),
1459 rising,
1460 );
1461 assert_eq!(
1462 !host.fired().is_empty(),
1463 comparison == "Exiting" || comparison == "Outside",
1464 "{comparison}: changed geometry after bar {last}",
1465 );
1466 }
1467 let host = Host::new(host.engine.alerts());
1469 host.events();
1470 host.engine
1471 .update("SYM", Timeframe::Min1, &bars[..2], time(1020), rising);
1472 assert_eq!(
1473 !host.fired().is_empty(),
1474 comparison == "Entering" || comparison == "Inside"
1475 );
1476 }
1477 }
1478}