egui_charts/services/alerts.rs
1//! Price alerts the host keeps, evaluates and reports.
2
3use chrono::{DateTime, Utc};
4
5use super::Events;
6use crate::data::{MaybeSend, Reply};
7use crate::model::{Alert, AlertId};
8
9/// The host's alerts. The host stores and evaluates them, on its own prices or on a server; the
10/// library shows what the host pushes and sends every change the user makes through
11/// [`save`](Alerts::save) and [`remove`](Alerts::remove). With the `alert-engine` feature,
12/// `services::AlertEngine` is a ready-made implementation that evaluates
13/// the alerts on the bars the host feeds it; without it the library evaluates nothing.
14///
15/// The host is the source of truth. The terminal shows only the alerts and triggers that
16/// [`watch`](Alerts::watch) pushed: an alert the user creates appears once the host pushes it, and
17/// the terminal never marks an alert triggered on its own. When the host pushes
18/// [`AlertEvent::Triggered`], the terminal shows a popup and an entry in the alert log; the status
19/// it shows is the one the host last pushed in an [`AlertEvent::Upsert`]. Sound and delivery
20/// outside the application (email, push notifications) are the host's: it can play a sound or send
21/// a message when it pushes the trigger.
22///
23/// Evaluation is the host's, so these rules are the contract between the library that edits an
24/// alert and the service that runs it. A channel is inclusive at both boundaries. The state
25/// conditions [`InsideChannel`](crate::model::AlertCondition::InsideChannel) and
26/// [`OutsideChannel`](crate::model::AlertCondition::OutsideChannel) hold on every evaluation
27/// while the value is in that state, so pair them with a frequency that limits repeats, whereas
28/// entering and exiting hold only on the evaluation that crosses a boundary.
29/// [`AlertFrequency::OncePerMinute`](crate::model::AlertFrequency::OncePerMinute) allows one
30/// trigger per UTC clock minute, from `hh:mm:00` up to but excluding the next minute, whatever the
31/// chart's interval. An alert's [`schedule`](crate::model::Alert::schedule) allows triggers only when
32/// [`NotificationSchedule::allows`](crate::model::NotificationSchedule::allows) accepts the
33/// trigger time. Outside it, keep observing values for crossings without consuming an occurrence
34/// or disarming an alert that fires once.
35///
36/// A [`StrategyFills`](crate::model::AlertCondition::StrategyFills) alert receives each execution
37/// of a host-named run through `AlertEngine::strategy_fills`, with `strategies` and `alert-engine`.
38/// Its event carries the fill time, execution price and a message such as
39/// "Buy 2 at 187.50, position 4". The host supplies bars and retains the run; delivery outside the
40/// application remains the host's responsibility.
41///
42/// These methods are called on the UI thread, while the terminal shows a frame or runs a command,
43/// and must return at once. Do the work elsewhere and answer through the [`Reply`] or push through
44/// the [`Events`] you were given, from any thread, runtime or browser task. A `Reply` dropped
45/// unanswered reports [`FeedError::Unanswered`](crate::data::FeedError::Unanswered).
46///
47/// ```
48/// use std::sync::Mutex;
49///
50/// use egui_charts::data::Reply;
51/// use egui_charts::model::{Alert, AlertId};
52/// use egui_charts::services::{AlertEvent, Alerts, Events};
53///
54/// /// Alerts held in memory, lost when the application exits. Answers inline. A real host also
55/// /// evaluates the alerts on its prices and pushes `AlertEvent::Triggered` when one fires.
56/// #[derive(Default)]
57/// struct InMemory(Mutex<State>);
58///
59/// #[derive(Default)]
60/// struct State {
61/// alerts: Vec<Alert>,
62/// events: Option<Events<AlertEvent>>,
63/// next_id: u64,
64/// }
65///
66/// impl Alerts for InMemory {
67/// fn watch(&self, events: Events<AlertEvent>) {
68/// let mut state = self.0.lock().unwrap();
69/// for alert in &state.alerts {
70/// events.push(AlertEvent::Upsert(alert.clone()));
71/// }
72/// state.events = Some(events);
73/// }
74///
75/// fn save(&self, mut alert: Alert, reply: Reply<()>) {
76/// let mut state = self.0.lock().unwrap();
77/// if alert.id.is_none() {
78/// state.next_id += 1;
79/// alert.id = Some(AlertId(state.next_id.to_string()));
80/// }
81/// state.alerts.retain(|kept| kept.id != alert.id);
82/// state.alerts.push(alert.clone());
83/// if let Some(events) = &state.events {
84/// events.push(AlertEvent::Upsert(alert));
85/// }
86/// reply.send(Ok(()));
87/// }
88///
89/// fn remove(&self, id: AlertId, reply: Reply<()>) {
90/// let mut state = self.0.lock().unwrap();
91/// state.alerts.retain(|kept| kept.id.as_ref() != Some(&id));
92/// if let Some(events) = &state.events {
93/// events.push(AlertEvent::Remove(id));
94/// }
95/// reply.send(Ok(()));
96/// }
97/// }
98///
99/// let alerts: std::sync::Arc<dyn Alerts> = std::sync::Arc::new(InMemory::default());
100/// # let _ = alerts;
101/// ```
102pub trait Alerts: MaybeSend + 'static {
103 /// The local evaluator, when this service uses one. The assembled terminal feeds it from
104 /// its datafeed, including subjects that no live chart shows. Server-backed services keep
105 /// the default so evaluation remains on the server.
106 #[cfg(feature = "alert-engine")]
107 fn engine(&self) -> Option<&super::AlertEngine> {
108 None
109 }
110
111 /// Pushes the host's alerts, then every change and trigger. Called when the terminal starts and
112 /// again after a lagged stream, each time with a new stream that starts from no alerts: push
113 /// the current list first, then keep pushing until `events` is inactive. On the first call,
114 /// also push the triggers that fired while the application was closed; do not push them again
115 /// on a later call.
116 ///
117 /// The stream takes at most 10,000 pushes between two frames, the current list and the missed
118 /// triggers included: send at most that many at once (for example only the most recent
119 /// triggers), or the stream lags on every call and no alert shows.
120 fn watch(&self, events: Events<AlertEvent>);
121
122 /// Creates (`alert.id` is `None`) or replaces an alert. Keep `alert.inputs` with the alert:
123 /// its independent study parameters and source view must survive copies and restoration.
124 /// Answer `Ok` once the change is kept;
125 /// the alert appears when `watch` pushes it, so push the saved alert, with its id, as
126 /// [`AlertEvent::Upsert`].
127 fn save(&self, alert: Alert, reply: Reply<()>);
128
129 /// Deletes the alert `id`. Answer `Ok` once it is deleted and push [`AlertEvent::Remove`].
130 fn remove(&self, id: AlertId, reply: Reply<()>);
131}
132
133/// A change to the host's alerts, pushed through the stream [`Alerts::watch`] received.
134#[derive(Clone, Debug, PartialEq)]
135#[non_exhaustive]
136pub enum AlertEvent {
137 /// Adds the alert, or replaces the one with its id. An alert without an id is ignored.
138 Upsert(Alert),
139 /// Removes the alert with this id, if any.
140 Remove(AlertId),
141 /// Removes every alert, so a host that reconnects can push its full list again.
142 Clear,
143 /// The alert fired at `time`, at `price` (finite) when the host knows it. The terminal shows a
144 /// popup and logs it; push an `Upsert` as well when the alert's status changes.
145 Triggered {
146 /// Identifier of the alert that fired.
147 id: AlertId,
148 /// UTC instant of the trigger.
149 time: DateTime<Utc>,
150 /// Price observed by the host when the alert fired, when available.
151 price: Option<f64>,
152 },
153 /// A trigger with its actual subject, including a member of a watchlist alert.
154 /// The panel retains this symbol even after the alert or watchlist changes.
155 TriggeredForSymbol {
156 /// Identifier of the alert that fired.
157 id: AlertId,
158 /// Symbol whose live source satisfied the conditions.
159 symbol: String,
160 /// UTC instant of the trigger.
161 time: DateTime<Utc>,
162 /// Price observed by the host when available.
163 price: Option<f64>,
164 /// The alert's message with its placeholders filled in, as [`Alert::expand_message`]
165 /// does, empty if the alert has none. The terminal shows it in the popup and the log.
166 message: String,
167 },
168}