Skip to main content

egui_charts/model/
alert.rs

1//! Typed alerts, as a host or a server stores and evaluates them.
2
3use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};
4use serde::{Deserialize, Serialize};
5
6use super::{Bar, Timeframe, TimezoneId};
7
8/// The host's identifier of an alert.
9#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct AlertId(
11    /// The host's own identifier, unique among its alerts.
12    pub String,
13);
14
15/// An alert as the host stores and evaluates it. The library creates and edits alerts through
16/// [`Alerts::save`](crate::services::Alerts::save) and shows the ones the host pushes. It evaluates
17/// them only through the host's service; `AlertEngine` (feature `alert-engine`) is one.
18///
19/// It is serialisable so that a server can store and evaluate the same type. Fields added later
20/// will carry `#[serde(default)]`, so alerts stored by an earlier version keep loading. A
21/// condition, frequency or status added later does not load in an earlier version.
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
23#[non_exhaustive]
24pub struct Alert {
25    /// `None` for an alert the user is creating; the host assigns the id.
26    #[serde(default)]
27    pub id: Option<AlertId>,
28    /// The symbol whose bars supply the sources, unless `watchlist` names the subject.
29    pub symbol: String,
30    /// User-facing name, independent of the notification message.
31    #[serde(default)]
32    pub name: String,
33    /// Host watchlist identifier. When present, evaluate independently on each member symbol.
34    #[serde(default)]
35    pub watchlist: Option<String>,
36    /// The timeframe whose bars the alert is evaluated on. `None` for an alert that has not been
37    /// fixed to one yet; `AlertEngine` fixes it to the first timeframe it evaluates the alert on.
38    #[serde(default)]
39    pub timeframe: Option<Timeframe>,
40    /// Independent creation inputs, retained through edits, copies and storage. Legacy alerts
41    /// without these inputs cannot recover a study from a chart-local identifier.
42    #[serde(default)]
43    pub inputs: Option<Box<AlertInputs>>,
44    /// Value tested by the first condition.
45    #[serde(default)]
46    pub source: AlertSource,
47    /// Additional conditions, all required on the same bar as the first condition.
48    #[serde(default)]
49    pub additional_conditions: Vec<AlertRule>,
50    /// When the alert fires.
51    pub condition: AlertCondition,
52    /// Whether the alert fires once or on every occurrence.
53    #[serde(default)]
54    pub frequency: AlertFrequency,
55    /// The alert expires at this time; `None` never expires.
56    #[serde(default)]
57    pub expires: Option<DateTime<Utc>>,
58    /// When the alert may trigger; `None` allows any time. Outside the schedule, evaluation keeps
59    /// observing values for crossings without firing or consuming an occurrence.
60    #[serde(default)]
61    pub schedule: Option<Box<NotificationSchedule>>,
62    /// Text the host shows or sends when the alert fires. [`Alert::expand_message`] fills in the
63    /// placeholders `{{symbol}}`, `{{close}}` and so on when it fires.
64    #[serde(default)]
65    pub message: String,
66    /// Where the alert stands, as the host reports it.
67    #[serde(default)]
68    pub status: AlertStatus,
69}
70
71/// The source view and study definitions captured when an alert is created. The alert's
72/// symbol and timeframe select its main series; auxiliary requests follow these saved studies.
73#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
74pub struct AlertInputs {
75    /// Calendar zone; `None` follows the system's local calendar.
76    pub timezone: Option<TimezoneId>,
77    /// Exchange calendar of the creation symbol. Other symbols resolve their own calendars.
78    pub calendar: Option<AlertCalendar>,
79    /// Source choices shown when editing, independent of the currently displayed chart.
80    pub sources: Vec<(AlertSource, String)>,
81    /// Include extended trading sessions.
82    pub extended_hours: bool,
83    /// Host currency or unit conversion.
84    pub conversion: Option<String>,
85    /// Initial main-series history size when no start time was available.
86    pub count: usize,
87    /// Oldest main bar used at creation. Later evaluations retain this seed.
88    pub start: Option<DateTime<Utc>>,
89    /// Creation window for studies whose calculation depends on visible bars, inclusive.
90    pub visible: Option<(DateTime<Utc>, DateTime<Utc>)>,
91    /// Drawing levels interpolate logarithmically on a logarithmic source scale.
92    pub logarithmic: bool,
93    /// Independent calculation definitions, including study dependencies.
94    pub studies: Vec<AlertStudy>,
95}
96
97impl Default for AlertInputs {
98    fn default() -> Self {
99        Self {
100            timezone: Some(TimezoneId::utc()),
101            calendar: None,
102            sources: Vec::new(),
103            extended_hours: false,
104            conversion: None,
105            count: 1000,
106            start: None,
107            visible: None,
108            logarithmic: false,
109            studies: Vec::new(),
110        }
111    }
112}
113
114impl AlertInputs {
115    /// Offer newly available study sources during an explicit edit. Existing identifiers keep
116    /// their original definitions, including when another chart reuses an identifier.
117    pub fn add_sources_from(&mut self, other: &Self) {
118        for study in &other.studies {
119            if !self.studies.iter().any(|saved| saved.id == study.id) {
120                self.studies.push(study.clone());
121            }
122        }
123        for (source, name) in &other.sources {
124            if !self.sources.iter().any(|(saved, _)| saved == source) {
125                self.sources.push((source.clone(), name.clone()));
126            }
127        }
128    }
129}
130
131/// The creation symbol's exchange calendar, including an explicitly continuous session.
132#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
133pub struct AlertCalendar {
134    /// Symbol whose calendar was captured.
135    pub symbol: String,
136    /// Exchange zone, when supplied by the host.
137    pub timezone: Option<TimezoneId>,
138    /// Trading windows; `None` selects a continuous session.
139    pub session: Option<super::Session>,
140}
141
142/// Serializable calculation inputs of a study, without computed values or shared stream state.
143#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
144pub struct AlertStudy {
145    /// Identifier used by the alert sources and by dependent studies.
146    pub id: usize,
147    /// Name resolved by the study factory.
148    pub name: String,
149    /// Typed parameters, including auxiliary symbols and intervals.
150    pub inputs: Vec<(String, super::InputValue)>,
151}
152
153impl Alert {
154    /// An active alert on `symbol` with no id, no expiry and no message. Frequency is `Once`
155    /// for bar conditions and `EveryTime` for strategy fills.
156    pub fn new(symbol: impl Into<String>, condition: AlertCondition) -> Self {
157        Self {
158            id: None,
159            symbol: symbol.into(),
160            name: String::new(),
161            watchlist: None,
162            timeframe: None,
163            inputs: None,
164            source: AlertSource::Price,
165            additional_conditions: Vec::new(),
166            frequency: if matches!(condition, AlertCondition::StrategyFills(_)) {
167                AlertFrequency::EveryTime
168            } else {
169                AlertFrequency::default()
170            },
171            condition,
172            expires: None,
173            schedule: None,
174            message: String::new(),
175            status: AlertStatus::default(),
176        }
177    }
178}
179
180impl Alert {
181    /// Every input tested by the alert, including comparison outputs and additional rules.
182    pub fn sources(&self) -> impl Iterator<Item = &AlertSource> {
183        std::iter::once((&self.source, &self.condition))
184            .chain(
185                self.additional_conditions
186                    .iter()
187                    .map(|rule| (&rule.source, &rule.condition)),
188            )
189            .flat_map(|(source, condition)| {
190                std::iter::once(source).chain(condition.partners().into_iter().flatten())
191            })
192    }
193
194    /// The message with its placeholders filled in from the trigger: `{{symbol}}` and
195    /// `{{timeframe}}` as given, `{{time}}` the open time of `bar` in RFC 3339, `{{open}}`,
196    /// `{{high}}`, `{{low}}`, `{{close}}` and `{{volume}}` of `bar`, `{{value}}` the value of the
197    /// alert's source and `{{output_N}}` the value of output `N` of the study the source names,
198    /// empty if the source is not a study. `value` gives the value of a source at the bar. A value
199    /// that is missing or not finite becomes empty text, a number is written in the shortest form
200    /// that reads back the same, and a placeholder the alert does not know stays as written.
201    ///
202    /// ```
203    /// use egui_charts::model::{Alert, AlertCondition, Bar, Timeframe};
204    ///
205    /// let mut alert = Alert::new("AAPL", AlertCondition::Above(100.0));
206    /// alert.message = "{{symbol}} closed at {{close}} on {{timeframe}}".into();
207    /// let time = chrono::DateTime::from_timestamp(0, 0).unwrap();
208    /// let bar = Bar::new(time, 99.0, 102.0, 98.0, 101.5, 1000.0);
209    /// assert_eq!(
210    ///     alert.expand_message("AAPL", Timeframe::Min5, &bar, |_| None),
211    ///     "AAPL closed at 101.5 on 5min"
212    /// );
213    /// ```
214    pub fn expand_message(
215        &self,
216        symbol: &str,
217        timeframe: Timeframe,
218        bar: &Bar,
219        value: impl Fn(&AlertSource) -> Option<f64>,
220    ) -> String {
221        let number = |number: Option<f64>| {
222            number
223                .filter(|number| number.is_finite())
224                .map(|number| number.to_string())
225                .unwrap_or_default()
226        };
227        let field = |name: &str| -> Option<String> {
228            Some(match name {
229                "symbol" => symbol.to_owned(),
230                "timeframe" => timeframe.to_string(),
231                "time" => bar.time.to_rfc3339(),
232                "open" => number(Some(bar.open)),
233                "high" => number(Some(bar.high)),
234                "low" => number(Some(bar.low)),
235                "close" => number(Some(bar.close)),
236                "volume" => number(Some(bar.volume)),
237                "value" => number(value(&self.source)),
238                _ => {
239                    let output = name.strip_prefix("output_")?.parse().ok()?;
240                    match &self.source {
241                        AlertSource::Study { id, .. } => number(value(&AlertSource::Study {
242                            id: id.clone(),
243                            output,
244                        })),
245                        _ => String::new(),
246                    }
247                }
248            })
249        };
250        let mut text = String::new();
251        let mut rest = self.message.as_str();
252        while let Some(start) = rest.find("{{")
253            && let Some(length) = rest[start + 2..].find("}}")
254        {
255            let end = start + 2 + length;
256            text.push_str(&rest[..start]);
257            match field(&rest[start + 2..end]) {
258                Some(filled) => text.push_str(&filled),
259                None => text.push_str(&rest[start..end + 2]),
260            }
261            rest = &rest[end + 2..];
262        }
263        text.push_str(rest);
264        text
265    }
266}
267
268/// When an alert fires, judged on its source for each subject symbol.
269/// Each variant states its rule so that a host and a server evaluate it alike. Levels are finite
270/// and in the symbol's price units.
271#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
272#[non_exhaustive]
273pub enum AlertCondition {
274    /// Each fill of the host's uniquely named strategy run. Feed `Backtest::update` results to
275    /// `AlertEngine::strategy_fills` with the same run id. Uses the price source, no additional
276    /// rules, and frequency `EveryTime` (the constructor default) or `Once`. No bars evaluate it.
277    StrategyFills(String),
278    /// The previous value was strictly below or above the level and the current value reaches
279    /// or passes it, in either direction.
280    Crossing(f64),
281    /// The previous price was below the level and the current price is at or above it.
282    CrossingUp(f64),
283    /// The previous price was above the level and the current price is at or below it.
284    CrossingDown(f64),
285    /// The price is above the level.
286    Above(f64),
287    /// The price is below the level.
288    Below(f64),
289    /// Previous value was outside the inclusive channel, current value is inside it.
290    EnteringChannel {
291        /// Inclusive lower boundary, strictly below `upper`.
292        lower: f64,
293        /// Inclusive upper boundary.
294        upper: f64,
295    },
296    /// Previous value was inside the inclusive channel, current value is outside it.
297    ExitingChannel {
298        /// Inclusive lower boundary, strictly below `upper`.
299        lower: f64,
300        /// Inclusive upper boundary.
301        upper: f64,
302    },
303    /// The current value is inside the inclusive channel, `lower <= value <= upper`, whatever the
304    /// previous value was.
305    InsideChannel {
306        /// Inclusive lower boundary, strictly below `upper`.
307        lower: f64,
308        /// Inclusive upper boundary.
309        upper: f64,
310    },
311    /// The current value is outside the inclusive channel, below `lower` or above `upper`,
312    /// whatever the previous value was.
313    OutsideChannel {
314        /// Inclusive lower boundary, strictly below `upper`.
315        lower: f64,
316        /// Inclusive upper boundary.
317        upper: f64,
318    },
319    /// Signed change from `bars` bars ago reaches `value`: at least for positive, at most for negative.
320    MovingBy {
321        /// Signed, nonzero change in source units.
322        value: f64,
323        /// Number of bars back, at least one.
324        bars: u32,
325    },
326    /// Signed percentage change, 100 * (current - previous) / abs(previous), over `bars` bars.
327    /// A zero previous value cannot satisfy this condition.
328    MovingByPercent {
329        /// Signed, nonzero percentage, with 1 meaning 1%.
330        percent: f64,
331        /// Number of bars back, at least one.
332        bars: u32,
333    },
334    /// Compare against another output on the same bar, using its previous value for crossings.
335    Compare {
336        /// The second output on the same symbol and bar.
337        other: AlertSource,
338        /// The required relationship between the outputs.
339        comparison: AlertComparison,
340    },
341    /// Test against two boundaries resolved on each evaluated bar. The boundaries may change
342    /// order; their minimum and maximum define the inclusive channel. Missing, non-finite or
343    /// coincident boundaries cannot satisfy the condition. On a new bar, transitions resolve the
344    /// source and both boundaries at the preceding bar again. Within a bar they compare with the
345    /// previous evaluation, just as a crossing does.
346    Channel {
347        /// Stable sources of the two boundary lines, retained through drawing edits and storage.
348        boundaries: [AlertSource; 2],
349        /// State or transition to test.
350        comparison: AlertChannelComparison,
351    },
352    /// Current volume exceeds `multiplier` times the mean of the preceding `bars` completed volumes.
353    VolumeAboveAverage {
354        /// Positive, finite multiple of the preceding mean volume.
355        multiplier: f64,
356        /// Number of preceding bars in the mean, at least one.
357        bars: u32,
358    },
359}
360
361/// A host-resolved value on a bar. Identifiers remain meaningful to the host across sessions.
362#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
363pub enum AlertSource {
364    /// The symbol's closing or latest price.
365    #[default]
366    Price,
367    /// A study instance and its zero-based output index.
368    Study {
369        /// Host's stable identifier for the study instance.
370        id: String,
371        /// Zero-based output line within the study.
372        output: usize,
373    },
374    /// A drawing's price at the bar's time, resolved by the host.
375    Drawing(String),
376    /// The bar's volume.
377    Volume,
378}
379
380/// Comparison of two outputs. Crossings require a strict previous side and include equality now.
381#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
382pub enum AlertComparison {
383    /// Cross in either direction.
384    Crossing,
385    /// Cross from below.
386    CrossingUp,
387    /// Cross from above.
388    CrossingDown,
389    /// Strictly greater.
390    Above,
391    /// Strictly less.
392    Below,
393}
394
395/// Relationship of a value to two inclusive, independently resolved boundaries.
396#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
397pub enum AlertChannelComparison {
398    /// Previously outside, currently inside.
399    Entering,
400    /// Previously inside, currently outside.
401    Exiting,
402    /// Currently inside, regardless of the previous value.
403    Inside,
404    /// Currently outside, regardless of the previous value.
405    Outside,
406}
407
408impl std::fmt::Display for AlertChannelComparison {
409    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410        f.write_str(match self {
411            Self::Entering => "Entering channel",
412            Self::Exiting => "Exiting channel",
413            Self::Inside => "Inside channel",
414            Self::Outside => "Outside channel",
415        })
416    }
417}
418
419/// One additional condition of an alert; all rules must hold on the same bar.
420#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
421pub struct AlertRule {
422    /// The value to test.
423    pub source: AlertSource,
424    /// The required relationship.
425    pub condition: AlertCondition,
426}
427
428impl AlertSource {
429    fn is_valid(&self) -> bool {
430        match self {
431            Self::Study { id, .. } | Self::Drawing(id) => !id.trim().is_empty(),
432            _ => true,
433        }
434    }
435}
436
437impl AlertCondition {
438    /// Other sources required on the same bar, in boundary order for a channel.
439    pub(crate) fn partners(&self) -> [Option<&AlertSource>; 2] {
440        match self {
441            Self::Compare { other, .. } => [Some(other), None],
442            Self::Channel {
443                boundaries: [a, b], ..
444            } => [Some(a), Some(b)],
445            _ => [None, None],
446        }
447    }
448
449    /// Whether levels are finite and ordered, lookbacks positive, and referenced ids nonempty.
450    pub fn is_valid(&self) -> bool {
451        match self {
452            Self::StrategyFills(run) => !run.trim().is_empty(),
453            Self::Crossing(v)
454            | Self::CrossingUp(v)
455            | Self::CrossingDown(v)
456            | Self::Above(v)
457            | Self::Below(v) => v.is_finite(),
458            Self::EnteringChannel { lower, upper }
459            | Self::ExitingChannel { lower, upper }
460            | Self::InsideChannel { lower, upper }
461            | Self::OutsideChannel { lower, upper } => {
462                lower.is_finite() && upper.is_finite() && lower < upper
463            }
464            Self::MovingBy { value, bars } => value.is_finite() && *value != 0.0 && *bars > 0,
465            Self::MovingByPercent { percent, bars } => {
466                percent.is_finite() && *percent != 0.0 && *bars > 0
467            }
468            Self::Compare { other, .. } => other.is_valid(),
469            Self::Channel {
470                boundaries: [a, b], ..
471            } => a != b && a.is_valid() && b.is_valid(),
472            Self::VolumeAboveAverage { multiplier, bars } => {
473                multiplier.is_finite() && *multiplier > 0.0 && *bars > 0
474            }
475        }
476    }
477
478    /// The single horizontal threshold, if the condition has one.
479    pub fn level(&self) -> Option<f64> {
480        match self {
481            Self::Crossing(v)
482            | Self::CrossingUp(v)
483            | Self::CrossingDown(v)
484            | Self::Above(v)
485            | Self::Below(v) => Some(*v),
486            _ => None,
487        }
488    }
489}
490
491impl std::fmt::Display for AlertCondition {
492    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493        match self {
494            Self::StrategyFills(run) => write!(f, "Fills of strategy {run}"),
495            Self::Crossing(v) => write!(f, "Crossing {v}"),
496            Self::CrossingUp(v) => write!(f, "Crossing up {v}"),
497            Self::CrossingDown(v) => write!(f, "Crossing down {v}"),
498            Self::Above(v) => write!(f, "Greater than {v}"),
499            Self::Below(v) => write!(f, "Less than {v}"),
500            Self::EnteringChannel { lower, upper } => write!(f, "Entering {lower} to {upper}"),
501            Self::ExitingChannel { lower, upper } => write!(f, "Exiting {lower} to {upper}"),
502            Self::InsideChannel { lower, upper } => write!(f, "Inside {lower} to {upper}"),
503            Self::OutsideChannel { lower, upper } => write!(f, "Outside {lower} to {upper}"),
504            Self::MovingBy { value, bars } => write!(f, "Moving by {value} over {bars} bars"),
505            Self::MovingByPercent { percent, bars } => {
506                write!(f, "Moving by {percent}% over {bars} bars")
507            }
508            Self::Compare { comparison, .. } => write!(f, "{} another output", comparison),
509            Self::Channel { comparison, .. } => write!(f, "{comparison}"),
510            Self::VolumeAboveAverage { multiplier, bars } => write!(
511                f,
512                "Volume above {multiplier} times the preceding {bars}-bar mean"
513            ),
514        }
515    }
516}
517
518impl Alert {
519    /// Validate user input before handing it to host storage or evaluation.
520    pub fn is_valid(&self) -> bool {
521        (!self.symbol.trim().is_empty()
522            || self
523                .watchlist
524                .as_ref()
525                .is_some_and(|id| !id.trim().is_empty()))
526            && self
527                .watchlist
528                .as_ref()
529                .is_none_or(|id| !id.trim().is_empty())
530            && (!matches!(self.condition, AlertCondition::StrategyFills(_))
531                || (self.source == AlertSource::Price
532                    && self.additional_conditions.is_empty()
533                    && matches!(
534                        self.frequency,
535                        AlertFrequency::EveryTime | AlertFrequency::Once
536                    )))
537            && self
538                .additional_conditions
539                .iter()
540                .all(|rule| !matches!(rule.condition, AlertCondition::StrategyFills(_)))
541            && self.source.is_valid()
542            && self.condition.is_valid()
543            && self
544                .additional_conditions
545                .iter()
546                .all(|rule| rule.source.is_valid() && rule.condition.is_valid())
547            && self
548                .schedule
549                .as_ref()
550                .is_none_or(|schedule| schedule.is_valid())
551    }
552}
553
554/// When an alert's notifications may be delivered: on the selected weekdays, inside the time
555/// windows, both read on the clock of `timezone`, so a daylight-saving change moves the windows
556/// with the local clock.
557#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
558pub struct NotificationSchedule {
559    /// The zone whose weekday and clock decide.
560    pub timezone: TimezoneId,
561    /// Weekdays on which a window starts, at least one.
562    pub days: Vec<Weekday>,
563    /// Delivery windows, at least one.
564    pub windows: Vec<NotificationWindow>,
565}
566
567/// A window of the day, from `start` up to but excluding `end`, in the schedule's zone. A window
568/// whose `end` is before `start` crosses midnight and belongs to the weekday it starts on.
569#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
570pub struct NotificationWindow {
571    /// First clock time inside the window.
572    pub start: NaiveTime,
573    /// First clock time after the window; different from `start`.
574    pub end: NaiveTime,
575}
576
577impl NotificationSchedule {
578    /// Whether at least one weekday and one window are selected and no window is empty.
579    pub fn is_valid(&self) -> bool {
580        !self.days.is_empty()
581            && !self.windows.is_empty()
582            && self.windows.iter().all(|w| w.start != w.end)
583    }
584
585    /// Whether a notification raised at `at` may be delivered.
586    pub fn allows(&self, at: DateTime<Utc>) -> bool {
587        let local = at.with_timezone(&self.timezone);
588        let (day, time) = (local.weekday(), local.time());
589        self.windows.iter().any(|w| {
590            if w.start < w.end {
591                self.days.contains(&day) && (w.start..w.end).contains(&time)
592            } else {
593                (self.days.contains(&day) && time >= w.start)
594                    || (self.days.contains(&day.pred()) && time < w.end)
595            }
596        })
597    }
598}
599
600/// How often an alert fires.
601#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
602#[non_exhaustive]
603pub enum AlertFrequency {
604    /// Fires once; the host then reports the alert with status `Triggered`.
605    #[default]
606    Once,
607    /// Stays active and fires on every new occurrence; how often a lasting condition fires again
608    /// is the host's choice.
609    EveryTime,
610    /// At most one trigger per bar, identified by its opening time.
611    OncePerBar,
612    /// Evaluate only completed bars, at most once per bar.
613    OncePerBarClose,
614    /// At most one trigger per clock minute of UTC, identified by the minute's start, whatever the
615    /// chart's interval.
616    OncePerMinute,
617}
618
619impl std::fmt::Display for AlertFrequency {
620    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621        f.write_str(match self {
622            Self::Once => "Only once",
623            Self::EveryTime => "Every time",
624            Self::OncePerBar => "Once per bar",
625            Self::OncePerBarClose => "Once per bar close",
626            Self::OncePerMinute => "Once per minute",
627        })
628    }
629}
630
631impl std::fmt::Display for AlertComparison {
632    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
633        f.write_str(match self {
634            Self::Crossing => "Crossing",
635            Self::CrossingUp => "Crossing up",
636            Self::CrossingDown => "Crossing down",
637            Self::Above => "Greater than",
638            Self::Below => "Less than",
639        })
640    }
641}
642
643/// Where an alert stands. The host sets it; the library only shows it.
644#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
645#[non_exhaustive]
646pub enum AlertStatus {
647    /// Evaluated and waiting for its condition.
648    #[default]
649    Active,
650    /// Fired and no longer evaluated, as an alert with frequency `Once` is after it fires.
651    Triggered,
652    /// Kept but not evaluated until the user enables it again.
653    Disabled,
654    /// Its expiry time passed before it fired.
655    Expired,
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn stored_named_compound_alert_round_trips() {
664        let stored = serde_json::json!({
665            "symbol": "AAPL", "name": "Momentum", "message": "Check momentum",
666            "watchlist": "technology", "source": {"Study": {"id": "rsi", "output": 0}},
667            "condition": {"Above": 70.0}, "frequency": "OncePerBarClose",
668            "additional_conditions": [{"source": "Price", "condition": {"EnteringChannel": {"lower": 100.0, "upper": 110.0}}}]
669        });
670        let alert: Alert = serde_json::from_value(stored.clone()).unwrap();
671        let saved = serde_json::to_value(alert).unwrap();
672        for (key, value) in stored.as_object().unwrap() {
673            assert_eq!(saved.get(key), Some(value), "{key}");
674        }
675    }
676
677    #[test]
678    fn messages_fill_placeholders_from_the_evaluated_bar() {
679        let time = DateTime::from_timestamp(1_704_067_200, 0).unwrap();
680        let bar = Bar::new(time, 99.0, 102.5, f64::NAN, 101.5, 1000.0);
681        let study = AlertSource::Study {
682            id: "3".into(),
683            output: 0,
684        };
685        // Output 0 of the study is 70.25 and output 1 is 3; no other output has a value.
686        let value = |source: &AlertSource| match source {
687            AlertSource::Study { id, output: 0 } if id == "3" => Some(70.25),
688            AlertSource::Study { id, output: 1 } if id == "3" => Some(3.0),
689            AlertSource::Price => Some(101.5),
690            _ => None,
691        };
692        for (message, source, expected) in [
693            (
694                "{{symbol}} closed at {{close}}",
695                AlertSource::Price,
696                "AAPL closed at 101.5",
697            ),
698            (
699                "{{timeframe}} {{time}} {{open}} {{high}} [{{low}}] {{volume}}",
700                AlertSource::Price,
701                "5min 2024-01-01T00:00:00+00:00 99 102.5 [] 1000",
702            ),
703            ("{{value}}/{{output_1}}", study.clone(), "70.25/3"),
704            ("{{output_2}}.", study.clone(), "."),
705            ("{{value}}{{output_1}}", AlertSource::Price, "101.5"),
706            (
707                "{{ close }} {{nope}} {{output_x}} {{close",
708                AlertSource::Price,
709                "{{ close }} {{nope}} {{output_x}} {{close",
710            ),
711            ("{{{{close}}}}", AlertSource::Price, "{{{{close}}}}"),
712        ] {
713            let mut alert = Alert::new("AAPL", AlertCondition::Above(100.0));
714            alert.source = source;
715            alert.message = message.into();
716            assert_eq!(
717                alert.expand_message("AAPL", Timeframe::Min5, &bar, value),
718                expected,
719                "{message}"
720            );
721        }
722    }
723
724    #[test]
725    fn saved_alerts_require_finite_levels_ordered_channels_and_positive_lookbacks() {
726        type Edit = fn(&mut Alert);
727        let rows: [(Edit, bool); 22] = [
728            (
729                |a| a.condition = AlertCondition::StrategyFills("run".into()),
730                true,
731            ),
732            (
733                |a| a.condition = AlertCondition::StrategyFills(" ".into()),
734                false,
735            ),
736            (
737                |a| {
738                    a.condition = AlertCondition::StrategyFills("run".into());
739                    a.frequency = AlertFrequency::OncePerBar;
740                },
741                false,
742            ),
743            (
744                |a| {
745                    a.additional_conditions.push(AlertRule {
746                        source: AlertSource::Price,
747                        condition: AlertCondition::StrategyFills("run".into()),
748                    });
749                },
750                false,
751            ),
752            (|a| a.condition = AlertCondition::Crossing(f64::NAN), false),
753            (
754                |a| a.condition = AlertCondition::Above(f64::INFINITY),
755                false,
756            ),
757            (|a| a.condition = AlertCondition::Below(-12.5), true),
758            (
759                |a| {
760                    a.condition = AlertCondition::InsideChannel {
761                        lower: 1.0,
762                        upper: 2.0,
763                    }
764                },
765                true,
766            ),
767            (
768                |a| {
769                    a.condition = AlertCondition::OutsideChannel {
770                        lower: 2.0,
771                        upper: 2.0,
772                    }
773                },
774                false,
775            ),
776            (
777                |a| {
778                    a.schedule = Some(Box::new(NotificationSchedule {
779                        timezone: TimezoneId::utc(),
780                        days: Vec::new(),
781                        windows: vec![NotificationWindow {
782                            start: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
783                            end: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
784                        }],
785                    }))
786                },
787                false,
788            ),
789            (
790                |a| {
791                    a.schedule = Some(Box::new(NotificationSchedule {
792                        timezone: TimezoneId::utc(),
793                        days: vec![Weekday::Mon],
794                        windows: vec![NotificationWindow {
795                            start: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
796                            end: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
797                        }],
798                    }))
799                },
800                false,
801            ),
802            (
803                |a| {
804                    a.condition = AlertCondition::EnteringChannel {
805                        lower: 2.0,
806                        upper: 1.0,
807                    }
808                },
809                false,
810            ),
811            (
812                |a| {
813                    a.condition = AlertCondition::ExitingChannel {
814                        lower: 1.0,
815                        upper: 1.0,
816                    }
817                },
818                false,
819            ),
820            (
821                |a| {
822                    a.condition = AlertCondition::MovingBy {
823                        value: 1.0,
824                        bars: 0,
825                    }
826                },
827                false,
828            ),
829            (
830                |a| {
831                    a.condition = AlertCondition::MovingByPercent {
832                        percent: 0.0,
833                        bars: 1,
834                    }
835                },
836                false,
837            ),
838            (
839                |a| {
840                    a.condition = AlertCondition::VolumeAboveAverage {
841                        multiplier: -2.0,
842                        bars: 20,
843                    }
844                },
845                false,
846            ),
847            (
848                |a| {
849                    a.condition = AlertCondition::VolumeAboveAverage {
850                        multiplier: 2.0,
851                        bars: 0,
852                    }
853                },
854                false,
855            ),
856            (
857                |a| {
858                    a.source = AlertSource::Study {
859                        id: " ".into(),
860                        output: 0,
861                    }
862                },
863                false,
864            ),
865            (|a| a.symbol.clear(), false),
866            (
867                |a| {
868                    a.symbol.clear();
869                    a.watchlist = Some("technology".into());
870                },
871                true,
872            ),
873            (|a| a.watchlist = Some(" ".into()), false),
874            (
875                |a| {
876                    a.additional_conditions.push(AlertRule {
877                        source: AlertSource::Drawing(String::new()),
878                        condition: AlertCondition::Above(1.0),
879                    })
880                },
881                false,
882            ),
883        ];
884        for (index, (edit, valid)) in rows.into_iter().enumerate() {
885            let mut alert = Alert::new("SAMPLE", AlertCondition::Crossing(1.0));
886            edit(&mut alert);
887            assert_eq!(alert.is_valid(), valid, "row {index}");
888        }
889    }
890
891    #[test]
892    fn a_minimal_stored_alert_loads() {
893        let stored = r#"{"symbol":"AAPL","condition":{"CrossingUp":101.5}}"#;
894        let alert: Alert = serde_json::from_str(stored).unwrap();
895        assert_eq!(
896            alert,
897            Alert {
898                id: None,
899                name: String::new(),
900                source: AlertSource::Price,
901                watchlist: None,
902                timeframe: None,
903                inputs: None,
904                additional_conditions: Vec::new(),
905                symbol: "AAPL".into(),
906                condition: AlertCondition::CrossingUp(101.5),
907                frequency: AlertFrequency::Once,
908                expires: None,
909                schedule: None,
910                message: String::new(),
911                status: AlertStatus::Active,
912            }
913        );
914    }
915
916    #[cfg(feature = "timezones")]
917    #[test]
918    fn a_stored_schedule_round_trips_and_a_window_may_cross_midnight() {
919        let stored = serde_json::json!({
920            "symbol": "AAPL", "condition": {"InsideChannel": {"lower": 1.0, "upper": 2.0}},
921            "frequency": "OncePerMinute",
922            "schedule": {"timezone": "America/New_York", "days": ["Fri"],
923                "windows": [{"start": "22:00:00", "end": "02:00:00"}]}
924        });
925        let alert: Alert = serde_json::from_value(stored.clone()).unwrap();
926        assert_eq!(
927            serde_json::to_value(&alert).unwrap()["schedule"],
928            stored["schedule"]
929        );
930        let schedule = alert.schedule.unwrap();
931        let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
932        // Friday 2026-03-06 22:30 EST is 03:30 UTC on Saturday: inside. Saturday 01:59 EST
933        // still belongs to Friday's window; 02:00 does not.
934        assert!(schedule.allows(at("2026-03-07T03:30:00Z")));
935        assert!(schedule.allows(at("2026-03-07T06:59:00Z")));
936        assert!(!schedule.allows(at("2026-03-07T07:00:00Z")));
937        // Thursday 22:30 EST: its weekday is not selected.
938        assert!(!schedule.allows(at("2026-03-06T03:30:00Z")));
939        // After the clocks go forward on Sunday 2026-03-08, Friday 22:30 is 02:30 UTC.
940        assert!(schedule.allows(at("2026-03-14T02:30:00Z")));
941        assert!(schedule.allows(at("2026-03-14T05:59:00Z")));
942        assert!(!schedule.allows(at("2026-03-14T06:00:00Z")));
943    }
944}