Skip to main content

egui_charts/widget/
alerts.rs

1//! The values and sources an alert can test on a chart.
2
3use crate::Chart;
4use crate::chart::series::SeriesId;
5use crate::model::AlertSource;
6
7impl Chart {
8    /// Lend loaded live bars to the alert evaluator. A paused or replaying chart cannot take
9    /// over an alert's live subscription, and loading history keeps its background reader.
10    #[cfg(feature = "alert-engine")]
11    pub fn alert_data(&self) -> Option<crate::services::AlertData<'_>> {
12        let link = self.feed_link.as_ref()?;
13        if link.paused
14            || self.replay.is_some()
15            || self.feed.loading_history
16            || self.feed.last_error.is_some()
17            || self.bars(SeriesId::MAIN).is_empty()
18        {
19            return None;
20        }
21        let mut request =
22            crate::services::AlertFeed::history_request(self.symbol(), self.timeframe()?);
23        request.clock = self.bar_clock();
24        request.extended_hours = self.extended_hours();
25        request.adjusted = self.dividend_adjusted();
26        request.conversion = self.conversion().map(str::to_owned);
27        Some(crate::services::AlertData {
28            request,
29            bars: self.bars(SeriesId::MAIN),
30            history_complete: self.feed.history_complete,
31            #[cfg(feature = "ui")]
32            drawings: &self.drawings().drawings,
33        })
34    }
35
36    /// Capture calculation inputs for alert creation, independently of later chart changes.
37    /// Copies and edits retain the inputs already carried by the alert.
38    pub fn prepare_alert(&self, alert: &mut crate::model::Alert) {
39        alert.timeframe = alert.timeframe.or(self.timeframe());
40        if let Some(inputs) = &mut alert.inputs {
41            inputs.add_sources_from(&self.alert_inputs());
42        } else if alert.id.is_none() {
43            let mut inputs = self.alert_inputs();
44            if alert.symbol != self.symbol()
45                || alert.timeframe != self.timeframe()
46                || alert.watchlist.is_some()
47            {
48                inputs.start = None;
49                inputs.visible = None;
50                inputs.count = inputs.count.max(1000);
51            }
52            alert.inputs = Some(Box::new(inputs));
53        }
54    }
55
56    /// A snapshot for an inline alert form. Capture again when the form is shown; submitting it
57    /// copies this view into the alert, so storage never needs the chart to recover its inputs.
58    pub fn alert_inputs(&self) -> crate::model::AlertInputs {
59        let bars = self.bars(SeriesId::MAIN);
60        let (left, right) = self.state.visible_range();
61        crate::model::AlertInputs {
62            timezone: match &self.chart_options().time_scale.timezone {
63                crate::config::TimezoneMode::Local => None,
64                zone => Some(zone.to_tz().unwrap_or_else(crate::model::TimezoneId::utc)),
65            },
66            calendar: self
67                .instrument()
68                .map(|instrument| crate::model::AlertCalendar {
69                    symbol: self.symbol().to_owned(),
70                    timezone: instrument.time_zone.clone(),
71                    session: instrument.session.clone(),
72                }),
73            sources: self.alert_sources(),
74            extended_hours: self.extended_hours(),
75            conversion: self.conversion().map(str::to_owned),
76            count: if bars.is_empty() { 1000 } else { bars.len() },
77            start: bars.first().map(|bar| bar.time),
78            visible: bars
79                .get(left)
80                .zip(bars.get(right.min(bars.len()).saturating_sub(1)))
81                .map(|(first, last)| (first.time, last.time)),
82            logarithmic: self.config().right_axis_scale_mode
83                == crate::scales::PriceScaleMode::Logarithmic,
84            studies: self
85                .indicators()
86                .map(|(id, study)| crate::model::AlertStudy {
87                    id: id.0,
88                    name: study.name().to_owned(),
89                    inputs: study
90                        .inputs()
91                        .into_iter()
92                        .map(|input| (input.id, input.value))
93                        .collect(),
94                })
95                .collect(),
96        }
97    }
98
99    /// The value `source` has at bar `bar` of the main series, which `AlertEngine::update` takes as
100    /// its `value` function and any other evaluator can use: the close for
101    /// [`Price`](AlertSource::Price), the volume for [`Volume`](AlertSource::Volume), the value of
102    /// the study output at that bar for a [`Study`](AlertSource::Study) named by its [`SeriesId`]
103    /// in decimal, and the price of the drawing's level at that bar for a
104    /// [`Drawing`](AlertSource::Drawing) named as [`Drawing::alert_source`] does.
105    ///
106    /// `None` for an unknown study, output or drawing, a bar the series does not have, and a value
107    /// that is not finite.
108    ///
109    /// [`Drawing::alert_source`]: crate::drawings::Drawing::alert_source
110    pub fn alert_value(&self, source: &AlertSource, bar: usize) -> Option<f64> {
111        let at = self.bars(SeriesId::MAIN).get(bar)?;
112        let value = match source {
113            AlertSource::Price => at.close,
114            AlertSource::Volume => at.volume,
115            AlertSource::Study { id, output } => self
116                .indicators()
117                .find(|(study, _)| study.0.to_string() == *id)?
118                .1
119                .plot_value(*output, bar)?,
120            AlertSource::Drawing(_) => {
121                let scale = self.config().right_axis_scale_mode;
122                self.drawings().drawings.iter().find_map(|d| {
123                    let level = d.alert_level(source)?;
124                    d.alert_levels_with_bars(bar as f64, scale, self.bars(SeriesId::MAIN))
125                        .into_iter()
126                        .find(|(index, ..)| *index == level)
127                        .map(|(.., price)| price)
128                })?
129            }
130        };
131        value.is_finite().then_some(value)
132    }
133
134    /// Every output of every study and every level of every completed drawing, with the name the
135    /// alert dialog shows, for `AlertDialog::sources`. Drawing levels are those at the newest bar.
136    pub fn alert_sources(&self) -> Vec<(AlertSource, String)> {
137        let bar = self.bars(SeriesId::MAIN).len().saturating_sub(1) as f64;
138        let scale = self.config().right_axis_scale_mode;
139        let studies = self.indicators().flat_map(|(id, study)| {
140            study
141                .line_names()
142                .into_iter()
143                .enumerate()
144                .map(move |(output, name)| {
145                    (
146                        AlertSource::Study {
147                            id: id.0.to_string(),
148                            output,
149                        },
150                        format!("{}: {name}", study.name()),
151                    )
152                })
153        });
154        let drawings = self
155            .drawings()
156            .drawings
157            .iter()
158            .filter(|d| d.completed)
159            .flat_map(|d| {
160                let levels = d.alert_levels_with_bars(bar, scale, self.bars(SeriesId::MAIN));
161                let single = levels.len() == 1;
162                levels.into_iter().map(move |(level, name, _)| {
163                    let drawing = format!("{} {}", d.tool_type.as_str(), d.id);
164                    (
165                        d.alert_source(level),
166                        if single {
167                            drawing
168                        } else {
169                            format!("{drawing}: {name}")
170                        },
171                    )
172                })
173            });
174        studies.chain(drawings).collect()
175    }
176}
177
178#[cfg(all(test, feature = "studies"))]
179mod tests {
180    use super::*;
181    use crate::drawings::{ChartPoint, Drawing, DrawingToolType};
182    use crate::model::{Bar, BarData};
183    use chrono::DateTime;
184
185    /// A moving average and a trend line resolve at a bar to the values drawn there.
186    #[test]
187    fn study_and_drawing_sources_resolve_to_the_values_painted_at_a_bar() {
188        let bars = [10.0, 20.0, 30.0, 40.0]
189            .into_iter()
190            .enumerate()
191            .map(|(i, close)| {
192                let time = DateTime::from_timestamp(60 * i as i64, 0).unwrap();
193                Bar::new(time, close, close, close, close, 5.0 * (i + 1) as f64)
194            })
195            .collect();
196        let mut chart = Chart::new(BarData::from_bars(bars));
197        let study = chart.add_indicator(Box::new(crate::studies::SMA::new(2)));
198        let mut line = Drawing::new(7, DrawingToolType::TrendLine);
199        line.chart_points = vec![ChartPoint::new(0.0, 100.0), ChartPoint::new(2.0, 120.0)];
200        line.completed = true;
201        chart.drawings_mut().drawings.push(line);
202        let average = AlertSource::Study {
203            id: study.0.to_string(),
204            output: 0,
205        };
206        let trend = AlertSource::Drawing("7".into());
207
208        // Rows are (source, bar, value): the mean of the last two closes, and the line through
209        // (0, 100) and (2, 120), which rises 10 a bar.
210        for (source, bar, expected) in [
211            (AlertSource::Price, 3, Some(40.0)),
212            (AlertSource::Volume, 1, Some(10.0)),
213            (average.clone(), 3, Some(35.0)),
214            (average.clone(), 0, None),
215            (
216                AlertSource::Study {
217                    id: study.0.to_string(),
218                    output: 5,
219                },
220                3,
221                None,
222            ),
223            (
224                AlertSource::Study {
225                    id: "99".into(),
226                    output: 0,
227                },
228                3,
229                None,
230            ),
231            (trend.clone(), 3, Some(130.0)),
232            (trend.clone(), 4, None),
233            (AlertSource::Drawing("8".into()), 3, None),
234            (AlertSource::Price, 4, None),
235        ] {
236            assert_eq!(
237                chart.alert_value(&source, bar),
238                expected,
239                "{source:?} at {bar}"
240            );
241        }
242        assert_eq!(
243            chart.alert_sources(),
244            [
245                (average, "SMA: SMA(2)".to_owned()),
246                (trend, "Trend Line 7".to_owned())
247            ]
248        );
249    }
250    /// Fork medians and tines use anchor construction, and regression fits the supplied closes.
251    #[test]
252    fn fork_and_regression_sources_follow_edits_and_saved_geometry() {
253        let bars = [100.0, 104.0, 102.0, 108.0]
254            .into_iter()
255            .enumerate()
256            .map(|(i, close)| {
257                Bar::new(
258                    DateTime::from_timestamp(i as i64 * 60, 0).unwrap(),
259                    close,
260                    close,
261                    close,
262                    close,
263                    1.0,
264                )
265            })
266            .collect();
267        let mut chart = Chart::new(BarData::from_bars(bars));
268        chart.set_symbol("SAMPLE");
269        // A=(0,100), B=(1,200), C=(2,50), midpoint BC=(1.5,125).
270        // Standard slope=50/3, Schiff=-50/3, modified Schiff=-25, inside=-200/3.
271        for (tool, expected) in [
272            (
273                DrawingToolType::Pitchfork,
274                [150.0, 700.0 / 3.0, 200.0 / 3.0],
275            ),
276            (
277                DrawingToolType::SchiffPitchfork,
278                [100.0, 500.0 / 3.0, 100.0 / 3.0],
279            ),
280            (
281                DrawingToolType::ModifiedSchiffPitchfork,
282                [87.5, 150.0, 25.0],
283            ),
284            (
285                DrawingToolType::InsidePitchfork,
286                [25.0, 200.0 / 3.0, -50.0 / 3.0],
287            ),
288            (DrawingToolType::Pitchfan, [150.0, 400.0, 25.0]),
289        ] {
290            for scale in [
291                crate::scales::PriceScaleMode::Normal,
292                crate::scales::PriceScaleMode::Logarithmic,
293            ] {
294                chart.config_mut().right_axis_scale_mode = scale;
295                let log = scale == crate::scales::PriceScaleMode::Logarithmic;
296                let expected = expected.map(|v| if log { (v / 100.0_f64).exp() } else { v });
297                let mut drawing = Drawing::new(7, tool);
298                drawing.stored_id = Some("fork#saved".into());
299                drawing.chart_points = vec![
300                    ChartPoint::new(0.0, 100.0),
301                    ChartPoint::new(1.0, 200.0),
302                    ChartPoint::new(2.0, 50.0),
303                ];
304                if log {
305                    for point in &mut drawing.chart_points {
306                        point.price = (point.price / 100.0).exp();
307                    }
308                }
309                drawing.pitchfork = None;
310                drawing.completed = true;
311                chart.drawings_mut().drawings = vec![drawing];
312                let drawing = &chart.drawings().drawings[0];
313                // Legacy levels are 0, 0.5, 1, 1.5. Each setting owns two stable slots.
314                let sources = [
315                    drawing.alert_source(0),
316                    drawing.alert_source(4),
317                    drawing.alert_source(5),
318                ];
319                for (source, expected) in sources.iter().zip(expected) {
320                    let got = chart.alert_value(source, 3).unwrap();
321                    assert!(
322                        (got - expected).abs() < 1e-9,
323                        "{tool:?}: {source:?}: {got} != {expected}"
324                    );
325                    assert!(chart.alert_sources().iter().any(|(s, _)| s == source));
326                }
327                #[cfg(feature = "ui")]
328                {
329                    let mut menu = crate::ui::context_menu::drawing::DrawingContextMenu::new();
330                    menu.open_on(&egui::Context::default(), &chart, egui::Pos2::ZERO, 7);
331                    assert_eq!(
332                        menu.alert.unwrap().condition,
333                        crate::model::AlertCondition::Compare {
334                            other: sources[0].clone(),
335                            comparison: crate::model::AlertComparison::Crossing
336                        }
337                    );
338                    let mut config = crate::drawings::PitchforkConfig::legacy();
339                    config.levels[0].visible = false;
340                    chart.drawings_mut().drawings[0].pitchfork = Some(config);
341                    assert_eq!(chart.alert_value(&sources[0], 3), None);
342                    assert!(
343                        (chart.alert_value(&sources[1], 3).unwrap() - expected[1]).abs() < 1e-9
344                    );
345                    chart.drawings_mut().drawings[0].pitchfork = None;
346                    let stored = crate::drawings::persistence::drawing_to_stored(
347                        &chart.drawings().drawings[0],
348                        |i| (i * 60_000.0) as i64,
349                    );
350                    let saved = serde_json::to_string(&stored).unwrap();
351                    let restored = serde_json::from_str(&saved).unwrap();
352                    chart.drawings_mut().drawings[0] =
353                        crate::drawings::persistence::stored_to_drawing(
354                            &restored,
355                            |t| t as f32 / 60_000.0,
356                            99,
357                        );
358                    assert!(chart.alert_value(&sources[0], 3).is_some());
359                    chart.drawings_mut().drawings[0].chart_points[0].price += 10.0;
360                    assert_ne!(chart.alert_value(&sources[0], 3).unwrap(), expected[0]);
361                    for point in &mut chart.drawings_mut().drawings[0].chart_points {
362                        point.bar_idx = 0.0;
363                    }
364                    assert_eq!(chart.alert_value(&sources[0], 3), None);
365                    chart.drawings_mut().drawings.clear();
366                    assert_eq!(chart.alert_value(&sources[0], 3), None);
367                }
368            }
369        }
370        chart.config_mut().right_axis_scale_mode = crate::scales::PriceScaleMode::Normal;
371        // y=[100,104,102,108], x=[0,1,2,3]: mean=103.5, Sxx=5, Sxy=11.
372        // slope=2.2, intercept=100.2, residual SS=10.8, sigma=sqrt(10.8/3).
373        let mut regression = Drawing::new(8, DrawingToolType::RegressionTrend);
374        regression.chart_points = vec![ChartPoint::new(0.0, 999.0), ChartPoint::new(3.0, 1.0)];
375        regression.completed = true;
376        chart.drawings_mut().drawings = vec![regression];
377        let drawing = &chart.drawings().drawings[0];
378        for (level, expected) in [
379            (0, 106.8),
380            (1, 106.8 + 2.0 * 3.6_f64.sqrt()),
381            (2, 106.8 - 2.0 * 3.6_f64.sqrt()),
382        ] {
383            let got = chart.alert_value(&drawing.alert_source(level), 3).unwrap();
384            assert!(
385                (got - expected).abs() < 1e-9,
386                "regression {level}: {got} != {expected}"
387            );
388        }
389        #[cfg(feature = "ui")]
390        {
391            let mut menu = crate::ui::context_menu::drawing::DrawingContextMenu::new();
392            menu.open_on(&egui::Context::default(), &chart, egui::Pos2::ZERO, 8);
393            assert_eq!(
394                menu.alert.unwrap().condition,
395                crate::model::AlertCondition::Compare {
396                    other: AlertSource::Drawing("8".into()),
397                    comparison: crate::model::AlertComparison::Crossing
398                }
399            );
400        }
401        chart.drawings_mut().drawings[0].chart_points[1].bar_idx = 2.0;
402        assert!(
403            (chart
404                .alert_value(&AlertSource::Drawing("8".into()), 3)
405                .unwrap()
406                - 104.0)
407                .abs()
408                < 1e-9
409        );
410        chart.drawings_mut().drawings[0].chart_points[1].bar_idx = 3.0;
411        chart.config_mut().right_axis_scale_mode = crate::scales::PriceScaleMode::Logarithmic;
412        let got = chart
413            .alert_value(&AlertSource::Drawing("8".into()), 1)
414            .unwrap();
415        let expected = (100.2_f64.powi(2) * 106.8).cbrt();
416        assert!(
417            (got - expected).abs() < 1e-9,
418            "log regression interpolates its painted endpoints"
419        );
420        let drawing = &mut chart.drawings_mut().drawings[0];
421        drawing.chart_points[1].bar_idx = 0.0;
422        assert_eq!(
423            chart.alert_value(&AlertSource::Drawing("8".into()), 1),
424            None
425        );
426    }
427}