Skip to main content

egui_charts/services/
alert_engine.rs

1//! An alert service that evaluates alerts on the bars the host feeds it.
2
3use 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
15/// An [`Alerts`] service that keeps alerts in memory and evaluates them on the bars the host
16/// feeds it with [`update`](Self::update), as [`PaperAccount`](super::PaperAccount) fills orders
17/// on the prices it is fed. It needs no egui, so a server can run the same code. A host that
18/// evaluates alerts on its own server implements [`Alerts`] itself instead.
19/// With `studies`, `AlertEvaluator` reads the saved source definitions from a datafeed and
20/// supplies these evaluations without constructing a chart.
21///
22/// The host keeps the alerts: [`alerts`](Self::alerts) returns them with their status for
23/// storage, and [`with_alerts`](Self::with_alerts) restores them. Evaluation state lives in
24/// memory only, so after a restart an alert that fires on every occurrence may fire again while
25/// its condition still holds; an alert with frequency [`Once`](AlertFrequency::Once) that fired
26/// stays [`Triggered`](AlertStatus::Triggered).
27///
28/// # Rules
29///
30/// *Subject.* An alert is evaluated on its `symbol`, or on each member of its `watchlist` as
31/// [`set_watchlist`](Self::set_watchlist) gave it, with its own state per symbol. An update for
32/// another symbol, or for a timeframe other than the alert's, does not touch the alert. An alert
33/// without a timeframe takes the timeframe of the first update that reaches it, which the engine
34/// saves and pushes as an [`AlertEvent::Upsert`], so it is fixed from then on.
35///
36/// *Evaluated bar.* The newest bar, still forming, or for
37/// [`OncePerBarClose`](AlertFrequency::OncePerBarClose) the newest completed one. Every rule of
38/// an alert, the first and the additional conditions, is judged on that bar, and the alert holds
39/// when every rule holds. A missing or non-finite value makes its rule false. Price is the close
40/// and volume the volume of a bar; the `value` function of `update` resolves the other sources.
41///
42/// *Change.* An update whose evaluated bar, its close and volume and the value of every source
43/// equal those of the previous update does nothing, unless a per-minute alert enters a new
44/// minute or its schedule opens. An update
45/// whose evaluated bar is older than the newest one already evaluated for the alert is ignored:
46/// reloading history, rewinding a replay or switching to a coarser timeframe never fires an
47/// alert again.
48///
49/// *Crossings.* The previous value is the value at the alert's previous evaluation, within a bar
50/// and across bars, and on the first evaluation the value one bar earlier. Crossing up needs a
51/// previous value strictly below the level and a current value at or above it, crossing down the
52/// reverse; a value that touches the level and stays there fires once. Moving by and volume
53/// above average read the bars before the evaluated one, as [`AlertCondition`] documents.
54/// Moving channels resolve the preceding bar again when a new bar arrives, so edits and
55/// skipped updates use that bar's current geometry. Within a bar they use the previous evaluation.
56///
57/// *Frequency.* [`Once`](AlertFrequency::Once) fires on the first evaluation that holds and the
58/// alert becomes [`Triggered`](AlertStatus::Triggered); a watchlist alert fires once per member
59/// and stays active. [`EveryTime`](AlertFrequency::EveryTime) fires on every evaluation that
60/// holds, [`OncePerBar`](AlertFrequency::OncePerBar) at most once per bar open time, and
61/// [`OncePerBarClose`](AlertFrequency::OncePerBarClose) evaluates each completed bar once.
62/// [`OncePerMinute`](AlertFrequency::OncePerMinute) fires at most once per minute of the update
63/// time `now`; moving the clock backwards does not repeat a minute.
64///
65/// *Schedule.* An alert's `schedule` restricts triggers. Outside it the engine keeps observing
66/// values for crossings, but does not fire or consume an occurrence. A once alert stays armed.
67///
68/// *Expiry.* Every `update` and [`tick`](Self::tick) first marks active alerts whose expiry has
69/// passed as [`Expired`](AlertStatus::Expired), so an alert never fires at or after its expiry
70/// and expires without data. Disabled, triggered and expired alerts are not evaluated, and
71/// saving or removing an alert discards its evaluation state, so a resumed alert starts afresh.
72///
73/// ```
74/// use chrono::{DateTime, Utc};
75/// use egui_charts::model::{Alert, AlertCondition, Bar, Timeframe};
76/// use egui_charts::services::AlertEngine;
77///
78/// let mut alert = Alert::new("AAPL", AlertCondition::CrossingUp(100.0));
79/// alert.timeframe = Some(Timeframe::Min1);
80/// let engine = AlertEngine::new().with_alerts(vec![alert]);
81/// let time = |seconds| DateTime::<Utc>::from_timestamp(seconds, 0).unwrap();
82/// let bar = |seconds, close| Bar::new(time(seconds), close, close, close, close, 1.0);
83/// // The close rises through 100 on the second bar; `update` also takes the chart's source values.
84/// engine.update("AAPL", Timeframe::Min1, &[bar(0, 99.0), bar(60, 101.0)], time(61), |_, _| None);
85/// ```
86pub struct AlertEngine {
87    state: Mutex<State>,
88    deliver: Option<Box<dyn Deliver>>,
89}
90
91/// The host's callback for triggers.
92trait 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/// What the engine remembers of one alert on one symbol.
112#[derive(Default)]
113struct Run {
114    /// Open time of the newest bar evaluated.
115    newest: Option<DateTime<Utc>>,
116    /// The previous evaluation, which the next one is compared with.
117    seen: Option<Seen>,
118    /// The occurrence the alert last fired on: the open time of a bar, or the minute for a
119    /// per-minute alert.
120    fired: Option<DateTime<Utc>>,
121    /// A watchlist alert with frequency once has fired on this symbol.
122    done: bool,
123    #[cfg(feature = "strategies")]
124    last_fill: u64,
125}
126
127/// The inputs of one evaluation: the bar, its close and volume, and for each rule the value of
128/// its source and of the other output it compares with.
129#[derive(Clone, PartialEq)]
130struct Seen {
131    time: DateTime<Utc>,
132    scheduled: bool,
133    minute: Option<DateTime<Utc>>,
134    /// The bits of the close and volume, so that a value that is not a number equals itself.
135    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    /// An engine without alerts. Alerts saved without an id get "1", "2" and so on, skipping the
147    /// ids in use.
148    pub fn new() -> Self {
149        Self {
150            state: Mutex::new(State::default()),
151            deliver: None,
152        }
153    }
154
155    /// Calls `deliver` with the alert and its [`AlertEvent::TriggeredForSymbol`] on every trigger,
156    /// after the event is pushed, so the host can send email, push notifications or web requests.
157    /// The call comes from the thread that fed the engine and must return at once; it may use
158    /// the engine.
159    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    /// Starts with `alerts` as [`alerts`](Self::alerts) returned them, giving those without an
168    /// id one after the others.
169    pub fn with_alerts(self, alerts: Vec<Alert>) -> Self {
170        {
171            let mut state = self.lock();
172            // Ids are given once every kept id is known, so that none is given twice.
173            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    /// The alerts with their current status, for the host to keep.
188    pub fn alerts(&self) -> Vec<Alert> {
189        self.lock().alerts.clone()
190    }
191
192    /// Active symbols still armed for `id`, including watchlist members. A once-per-member
193    /// alert omits symbols that already fired, so a host can release their subscriptions.
194    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    /// Sets the members of the watchlist `id`, as the host's watchlist holds them. A member that
220    /// leaves loses its evaluation state, so it starts afresh if it returns.
221    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    /// Evaluates every active alert whose subject is `symbol` on `timeframe` against the newest
234    /// of `bars`, oldest first, at the time `now`. `value` resolves a study or drawing source at
235    /// a bar index of `bars`, for instance with `Chart::alert_value`; price and volume come from
236    /// `bars`. Call it each frame with the bars of every live chart, but not with a chart in
237    /// replay.
238    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    /// Evaluates one alert with its retained source definitions. Other alerts may refer to
250    /// different study instances with the same chart-local identifiers.
251    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    /// Delivers each new fill of `run` to matching strategy-fill alerts. Use a unique run id
352    /// for each `Backtest` instance, retained for its lifetime, and forward its update batches.
353    /// Repeated batches are ignored by sequence, including after edits or rearming an alert
354    /// on the same run and when multiple fills share a timestamp.
355    /// `now` gates expiry and notification schedules; event times are the source fill times.
356    /// Empty messages use the execution side, quantity, price and resulting signed position.
357    /// Custom messages expand the usual placeholders using a flat bar at the fill price, with
358    /// its source time and zero volume. Strategy-fill alerts need no price-reader subscription.
359    /// Delivery callbacks run after unlocking, as they do for price alerts.
360    #[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    /// Marks the alerts whose expiry has passed as expired, without waiting for data. Call it
469    /// at least once a second.
470    pub fn tick(&self, now: DateTime<Utc>) {
471        self.lock().expire(now);
472    }
473}
474
475impl State {
476    /// Adds `alert` or replaces the one with its id, giving it an id if it has none.
477    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                // Editing or rearming a run must not replay its executions.
499                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
568/// Evaluates `alert` on the newest of `bars` of `subject`, updating what `run` remembers. Returns
569/// the bar it fired on and its message, if it did.
570fn 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
674/// What a frequency fires at most once per: the bar, or for a per-minute alert the minute of `now`.
675fn 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
684/// Whether `condition` holds for `value`, given the previous value of the source, the current and
685/// previous value of the other output for a comparison, the bars up to the evaluated one and the
686/// value of the source `back` bars before it.
687fn 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
796/// Whether a signed `change` reaches `target`: at least for a positive one, at most for a negative.
797fn 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    /// A one-minute bar opening at minute `minute` that opens, highs, lows and closes at `close`.
817    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    /// An engine with `alerts` and the events it pushes since the last call of `events`.
829    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        /// The prices of the triggers pushed since the last call.
867        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    /// Feeds `bars` with the study value `study` of each bar, as one update.
886    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    /// A close, a volume and a study value; the history and the ticks of one row, and the ticks
894    /// on which the alert fires.
895    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        // History and ticks are (close, volume, study value). Each tick is one update of the
905        // forming bar after the history; the rows list the ticks on which the alert fires.
906        let n = f64::NAN;
907        let rows: Vec<Row> = vec![
908            // Touching the level and staying fires once; the repeated tick changes nothing.
909            (
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            // A missing value fails its rule and leaves no previous value: the next tick cannot
932            // cross, while a level needing no previous value holds at once.
933            (
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            // A repeated tick changes nothing, even when the volume is not a number.
946            (
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            // The other output moves too: its previous value is the one of the previous tick.
953            (
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            // Moving by reads the value `bars` bars before the evaluated bar, not the previous tick.
1007            (
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            // The mean of the three bars before the forming one is 20, so it takes more than 40.
1053            (
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        // Four frames of one rising feed: the first bar forming at 101 and then 102, then a new
1087        // bar forming at 103 and then 104. Each trigger reports the close of the evaluated bar.
1088        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            // The completed bars are the first, which is below the level, and then the second.
1107            (
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                // At the opening, the same forming bar may fire. A completed bar needs a new close.
1169                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        // UTC seconds -61, -60 and 0 belong to minutes 23:58, 23:59 and 00:00.
1202        // The chart bar never changes; neither boundaries nor occurrence counts use its time.
1203        for (now, count) in [
1204            (-61, 2),
1205            (-60, 2),
1206            (-1, 0),
1207            (0, 2),
1208            (59, 0),
1209            (60, 2),
1210            // A corrected clock must not fire a minute a second time.
1211            (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        // The first update fixes the timeframe, then the trigger comes.
1239        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        // The message is filled in the event, and the host callback gets the same event.
1247        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        // History that grew at the front, a rewound replay and another timeframe: nothing fires.
1255        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        // Expiry does not wait for data, and an expired alert never fires.
1306        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        // A disabled alert is ignored; saving it active again starts with a first observation.
1318        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        // Once reports the new status before the trigger.
1330        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        // A watchlist alert fires once for each member and stays active.
1338        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        // Invalid alerts are refused.
1361        assert!(matches!(
1362            host.save(Alert::new("SYM", AlertCondition::Above(f64::NAN))),
1363            Err(FeedError::InvalidData(_))
1364        ));
1365
1366        // A restored alert without an id never takes the id of a later one.
1367        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        // Price stays at 105. Bounds rise 10/bar: [90,100], [100,110], [110,120].
1377        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            // A same-bar edit moves both bounds, then one disappears. Recovery cannot invent a transition.
1418            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            // A later bar uses the edited drawing at the preceding bar, even after skipped updates.
1437            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            // First evaluation on bar 1 must use bar 0's bounds, not bar 1's bounds twice.
1468            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}