1use crate::Chart;
4use crate::chart::series::SeriesId;
5use crate::model::AlertSource;
6
7impl Chart {
8 #[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 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 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 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 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 #[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 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 #[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 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 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 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}