Skip to main content

egui_charts/strategies/
live.rs

1//! Incremental execution over host-supplied closed and forming bars.
2
3use super::*;
4
5/// One executed order, including an entry, an exit or a margin liquidation.
6/// A reversal is one fill whose quantity includes closing the previous position.
7#[derive(Clone, Debug, PartialEq)]
8#[non_exhaustive]
9pub struct StrategyFill {
10    /// Increasing identifier within this live run, starting at one. Retained across rebuilds.
11    pub sequence: u64,
12    /// Source bar time, or the finer bar time for a historical magnified execution.
13    pub time: DateTime<Utc>,
14    /// Identifier placed through the strategy context, or `"Margin call"` for liquidation.
15    pub order_id: String,
16    /// Executed order side.
17    pub side: OrderSide,
18    /// Execution price, including applicable slippage.
19    pub price: f64,
20    /// Total executed units, always positive.
21    pub quantity: f64,
22    /// Signed position immediately after this fill, before any fill callback orders execute.
23    pub position: f64,
24}
25
26#[derive(Clone, Debug, PartialEq)]
27pub(super) struct LiveState {
28    bars: Vec<Bar>,
29    lower: Vec<Bar>,
30    settings: BacktestSettings,
31    inputs: Vec<(String, String)>,
32    report: StrategyReport,
33    processed: usize,
34    forming: Option<(DateTime<Utc>, f64)>,
35    sequence: u64,
36    orders: Vec<Working>,
37    lots: Vec<Lot>,
38    trades: Vec<StrategyTrade>,
39    realized: f64,
40    rejected: usize,
41    tick: u64,
42}
43
44// Compare bits so unchanged invalid feed values do not cause an endless rebuild.
45fn same_bar(a: &Bar, b: &Bar) -> bool {
46    a.time == b.time
47        && [a.open, a.high, a.low, a.close, a.volume].map(f64::to_bits)
48            == [b.open, b.high, b.low, b.close, b.volume].map(f64::to_bits)
49}
50
51fn same_bars(a: &[Bar], b: &[Bar]) -> bool {
52    a.len() == b.len() && a.iter().zip(b).all(|(a, b)| same_bar(a, b))
53}
54
55fn same_lower(a: &[Bar], b: &[Bar], before: Option<DateTime<Utc>>) -> bool {
56    let historical = |bar: &&Bar| before.is_none_or(|at| bar.time < at);
57    let mut a = a.iter().filter(historical);
58    let mut b = b.iter().filter(historical);
59    loop {
60        match (a.next(), b.next()) {
61            (Some(a), Some(b)) if same_bar(a, b) => {}
62            (None, None) => return true,
63            _ => return false,
64        }
65    }
66}
67
68impl Backtest {
69    /// The current live report, available after the first [`Self::update`].
70    /// With tick recalculation, trades include forming-bar fills; equity marks are closed bars.
71    /// [`Self::run`] remains an independent historical calculation and does not change this state.
72    pub fn report(&self) -> Option<&StrategyReport> {
73        self.live.as_ref().map(|live| &live.report)
74    }
75
76    /// Continues a live strategy on `bars`, oldest first, with the last bar still forming.
77    /// Pass the same strategy instance and the full retained history on each call. The first
78    /// call processes the closed history and returns its fills; discard that batch when priming
79    /// a run that should alert only on subsequent fills. `lower` has the meaning it has in `run`.
80    ///
81    /// With `settings.recalculate_on_every_update` off, only newly closed bars execute, exactly
82    /// as in `run` over `bars` without its last bar. With it on, an observed forming bar walks
83    /// from its open to its close, then from its previous close to each new close, and decides
84    /// again with that forming bar included in the context. Its fills and orders are retained
85    /// when the bar closes; unseen intrabar extremes are never replayed over observed ticks.
86    /// Identical snapshots do nothing. Fill callbacks still exclude the unfinished bar.
87    ///
88    /// A correction before the previous forming bar, removed history, changed settings or inputs
89    /// rebuilds from the start using `Strategy::prepare` to reset decision state. Replayed fills
90    /// before the previous forming bar are not returned again. An observed forming bar can be
91    /// revised freely without a rebuild. Start a new `Backtest` for a different strategy instance.
92    ///
93    /// # Panics
94    ///
95    /// Panics if starting capital is not finite or bar timestamps are not strictly increasing.
96    pub fn update(
97        &mut self,
98        strategy: &mut dyn Strategy,
99        bars: &[Bar],
100        lower: &[Bar],
101    ) -> Vec<StrategyFill> {
102        assert!(
103            bars.windows(2).all(|pair| pair[0].time < pair[1].time),
104            "bar times must be strictly increasing"
105        );
106        let inputs = strategy.inputs();
107        let previous = self.live.take();
108        let replay_before = previous
109            .as_ref()
110            .and_then(|live| live.bars.last().map(|bar| bar.time));
111        let replayed_forming = previous.as_ref().is_some_and(|live| live.forming.is_some());
112        let replayed = |time| {
113            replay_before
114                .is_some_and(|before| time < before || (replayed_forming && time == before))
115        };
116        let sequence = previous.as_ref().map_or(0, |live| live.sequence);
117        let rebuild = previous.as_ref().is_none_or(|live| {
118            let prefix = live.bars.len().saturating_sub(1);
119            bars.len() < live.bars.len()
120                || !same_bars(&live.bars[..prefix], &bars[..prefix.min(bars.len())])
121                || live
122                    .bars
123                    .last()
124                    .zip(bars.get(prefix))
125                    .is_some_and(|(a, b)| a.time != b.time)
126                || live.settings != self.settings
127                || live.inputs != inputs
128                || live.report.name != self.name
129                || live.report.symbol != self.symbol
130                || live.report.timeframe != self.timeframe
131                || !same_lower(&live.lower, lower, replay_before)
132        });
133        let mut live = if rebuild {
134            Box::new(LiveState {
135                bars: Vec::new(),
136                lower: Vec::new(),
137                settings: self.settings.clone(),
138                inputs,
139                report: self.new_report(strategy, lower.len()),
140                processed: 0,
141                forming: None,
142                sequence,
143                orders: Vec::new(),
144                lots: Vec::new(),
145                trades: Vec::new(),
146                realized: 0.0,
147                rejected: 0,
148                tick: 0,
149            })
150        } else {
151            previous.expect("an existing live run")
152        };
153        if let Some((_, count)) = live
154            .report
155            .properties
156            .iter_mut()
157            .find(|(name, _)| name == "Lower-interval bars")
158        {
159            *count = lower.len().to_string();
160        }
161        let closed = bars.len().saturating_sub(1);
162        let tick_changed =
163            self.settings.recalculate_on_every_update && !same_bars(&live.bars, bars);
164        let changed = rebuild || live.processed < closed || tick_changed;
165        if !changed {
166            live.bars = bars.to_vec();
167            live.lower = lower.to_vec();
168            self.live = Some(live);
169            return Vec::new();
170        }
171        let calculation_bars = if self.settings.recalculate_on_every_update {
172            bars
173        } else {
174            &bars[..closed]
175        };
176        if rebuild {
177            strategy.prepare(calculation_bars);
178        } else {
179            strategy.prepare_update(calculation_bars);
180        }
181        let mut fills = Vec::new();
182        let mut run = Run {
183            orders: std::mem::take(&mut live.orders),
184            lots: std::mem::take(&mut live.lots),
185            trades: std::mem::take(&mut live.trades),
186            realized: live.realized,
187            rejected: live.rejected,
188            settings: &self.settings,
189            strategy,
190            history: &[],
191            trading: false,
192            tick: live.tick,
193            fills: Some(&mut fills),
194            forming: false,
195        };
196        let mut finer: Vec<_> = lower.iter().filter(|bar| valid_bar(bar)).collect();
197        finer.sort_by_key(|bar| bar.time);
198        finer.dedup_by_key(|bar| bar.time);
199        // Execution intervals learn the previous final bar's end when the next bar closes.
200        if self.timeframe.is_trade_based()
201            && self.settings.start_time.is_some()
202            && live.processed > 0
203            && live.processed < closed
204            && let Some(mark) = live.report.equity.last_mut()
205        {
206            mark.0 = bars[live.processed].time;
207        }
208        if live.report.equity.is_empty() {
209            run.start_equity(self.timeframe, &bars[..closed], &mut live.report);
210        }
211        for index in live.processed..closed {
212            let bar = &bars[index];
213            let observed = live.forming.is_some_and(|(time, _)| time == bar.time);
214            if observed {
215                if live.bars.get(index).is_none_or(|old| !same_bar(old, bar)) {
216                    run.forming_bar(bars, index, live.forming.map(|(_, price)| price), false);
217                }
218                live.forming = None;
219            }
220            run.closed_bar(&bars[..closed], index, &finer, &mut live.report, !observed);
221            if rebuild
222                && replayed(bar.time)
223                && let Some(fills) = &mut run.fills
224            {
225                fills.clear();
226            }
227        }
228        live.processed = closed;
229        if self.settings.recalculate_on_every_update
230            && let Some(bar) = bars.last()
231        {
232            let previous_price = live
233                .forming
234                .filter(|(time, _)| *time == bar.time)
235                .map(|(_, price)| price);
236            if run.forming_bar(bars, closed, previous_price, true) {
237                live.forming = Some((bar.time, bar.close));
238            }
239            if rebuild
240                && replayed(bar.time)
241                && let Some(fills) = &mut run.fills
242            {
243                fills.clear();
244            }
245        }
246        run.report_trades(&mut live.report);
247        live.orders = run.orders;
248        live.lots = run.lots;
249        live.trades = run.trades;
250        live.realized = run.realized;
251        live.rejected = run.rejected;
252        live.tick = run.tick;
253        for fill in &mut fills {
254            live.sequence += 1;
255            fill.sequence = live.sequence;
256        }
257        live.bars = bars.to_vec();
258        live.lower = lower.to_vec();
259        self.live = Some(live);
260        fills
261    }
262}
263
264impl<'a> Run<'a> {
265    fn forming_bar(
266        &mut self,
267        bars: &'a [Bar],
268        index: usize,
269        previous: Option<f64>,
270        decide: bool,
271    ) -> bool {
272        let bar = &bars[index];
273        self.trading = self
274            .settings
275            .start_time
276            .is_none_or(|start| bar.time >= start);
277        if !valid_bar(bar) {
278            return false;
279        }
280        self.history = &bars[..index];
281        self.forming = true;
282        if self.trading {
283            if previous.is_none() {
284                self.tick += 1;
285            }
286            self.fill_along(previous.unwrap_or(bar.open), bar.close, bar.time);
287        }
288        if decide {
289            self.history = &bars[..=index];
290            self.decide(bar.close, false);
291        }
292        true
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::strategies::StrategyFactory;
300
301    fn bar(minute: i64, close: f64) -> Bar {
302        Bar::new(
303            DateTime::UNIX_EPOCH + TimeDelta::minutes(minute),
304            close,
305            close + 2.0,
306            close - 2.0,
307            close,
308            1.0,
309        )
310    }
311
312    #[derive(Default)]
313    struct Script {
314        decisions: Vec<usize>,
315        resets: usize,
316    }
317
318    impl Strategy for Script {
319        fn prepare(&mut self, _: &[Bar]) {
320            self.decisions.clear();
321            self.resets += 1;
322        }
323        fn prepare_update(&mut self, _: &[Bar]) {}
324        fn on_bar(&mut self, context: &mut StrategyContext<'_>) {
325            if context.is_after_fill() {
326                return;
327            }
328            let index = context.bars().len();
329            self.decisions.push(index);
330            match index {
331                1 => context.entry("Long", OrderSide::Buy, 2.0, OrderType::Market),
332                3 => context.exit("Part", Some(1.0), None, None),
333                5 => context.entry("Short", OrderSide::Sell, 3.0, OrderType::Market),
334                7 => context.exit("Flat", None, None, None),
335                _ => {}
336            }
337        }
338    }
339
340    #[test]
341    fn closed_updates_match_full_runs_without_repeating_decisions() {
342        let bars: Vec<_> = [100., 101., 104., 102., 98., 95., 97., 99., 96.]
343            .into_iter()
344            .enumerate()
345            .map(|(i, price)| bar(i as i64, price))
346            .collect();
347        for (fill_on_close, start_time, magnifier, timeframe) in [
348            (false, None, false, Timeframe::Min1),
349            (true, None, false, Timeframe::Min1),
350            (false, Some(bars[2].time), false, Timeframe::Min1),
351            (true, Some(bars[2].time), true, Timeframe::Min1),
352            (
353                false,
354                Some(bars[0].time),
355                false,
356                Timeframe::Ticks(std::num::NonZeroU64::new(1).unwrap()),
357            ),
358            (
359                true,
360                Some(bars[0].time),
361                false,
362                Timeframe::Ticks(std::num::NonZeroU64::new(1).unwrap()),
363            ),
364            (
365                true,
366                None,
367                true,
368                Timeframe::Ticks(std::num::NonZeroU64::new(1).unwrap()),
369            ),
370            (
371                false,
372                Some(bars[2].time + TimeDelta::seconds(30)),
373                false,
374                Timeframe::Ticks(std::num::NonZeroU64::new(1).unwrap()),
375            ),
376        ] {
377            let mut engine = Backtest::new("Live", "SYM", timeframe, 10_000.0);
378            engine.settings.fill_on_close = fill_on_close;
379            engine.settings.start_time = start_time;
380            engine.settings.commission = Commission::PerOrder(1.0);
381            engine.settings.slippage = Slippage::Ticks {
382                ticks: 1,
383                tick_size: 0.25,
384            };
385            engine.settings.recalculate_after_fills = true;
386            let mut script = Script::default();
387            let mut sequence = 0;
388            for count in 0..=bars.len() {
389                let lower = if magnifier {
390                    bars[..count.saturating_sub(1)].to_vec()
391                } else {
392                    vec![]
393                };
394                let fills = engine.update(&mut script, &bars[..count], &lower);
395                for fill in fills {
396                    sequence += 1;
397                    assert_eq!(fill.sequence, sequence);
398                }
399                let full = engine.run(
400                    &mut Script::default(),
401                    &bars[..count.saturating_sub(1)],
402                    &lower,
403                );
404                let asynchronous = {
405                    let mut strategy = Script::default();
406                    let mut future = std::pin::pin!(engine.run_async(
407                        &mut strategy,
408                        &bars[..count.saturating_sub(1)],
409                        &lower,
410                    ));
411                    let mut context = std::task::Context::from_waker(std::task::Waker::noop());
412                    loop {
413                        if let std::task::Poll::Ready(report) =
414                            std::future::Future::poll(future.as_mut(), &mut context)
415                        {
416                            break report;
417                        }
418                    }
419                };
420                assert_eq!(asynchronous, full, "async prefix {count}");
421                assert_eq!(
422                    engine.report(),
423                    Some(&full),
424                    "prefix {count}, close {fill_on_close}, start {start_time:?}"
425                );
426                assert_eq!(script.decisions, (1..count).collect::<Vec<_>>());
427                assert!(
428                    engine
429                        .update(&mut script, &bars[..count], &lower)
430                        .is_empty()
431                );
432                assert_eq!(script.decisions, (1..count).collect::<Vec<_>>());
433            }
434            assert_eq!(script.resets, 1);
435            // Correcting closed history resets decisions, but does not repeat its fill events.
436            let mut corrected = bars.clone();
437            corrected[1] = bar(1, 120.0);
438            assert!(engine.update(&mut script, &corrected, &[]).is_empty());
439            assert_eq!(script.resets, 2);
440            assert_eq!(
441                engine.report(),
442                Some(&engine.run(&mut Script::default(), &corrected[..8], &[]))
443            );
444        }
445        // Every built-in refreshes its calculations while retaining its decision state.
446        let factory = StrategyFactory::new();
447        let source: Vec<_> = (0..80)
448            .map(|i| bar(i, 100.0 + f64::from((i % 13) as i32) * 2.0))
449            .collect();
450        for name in factory.list() {
451            let mut strategy = factory.create(name).unwrap();
452            let mut engine = Backtest::new(name, "SYM", Timeframe::Min1, 10_000.0);
453            for count in 1..=source.len() {
454                engine.update(strategy.as_mut(), &source[..count], &[]);
455            }
456            let full = engine.run(
457                factory.create(name).unwrap().as_mut(),
458                &source[..source.len() - 1],
459                &[],
460            );
461            assert_eq!(engine.report(), Some(&full), "{name}");
462        }
463    }
464
465    #[test]
466    fn forming_ticks_keep_fills_and_do_not_replay_extremes_at_close() {
467        struct TickStrategy;
468        impl Strategy for TickStrategy {
469            fn on_bar(&mut self, context: &mut StrategyContext<'_>) {
470                if context.is_after_fill() {
471                    return;
472                }
473                if context.price() >= 105.0 && context.position() == 0.0 {
474                    context.entry("Long", OrderSide::Buy, 2.0, OrderType::Market);
475                    context.exit("Target", None, Some(110.0), None);
476                }
477            }
478        }
479        let mut engine = Backtest::new("Ticks", "SYM", Timeframe::Min1, 10_000.0);
480        engine.settings.recalculate_on_every_update = true;
481        let mut bars = vec![bar(0, 100.0)];
482        let mut strategy = TickStrategy;
483        assert!(engine.update(&mut strategy, &bars, &[]).is_empty());
484        bars[0] = Bar::new(bars[0].time, 100., 120., 90., 106., 2.);
485        assert!(engine.update(&mut strategy, &bars, &[]).is_empty());
486        assert!(engine.update(&mut strategy, &bars, &[]).is_empty());
487        bars[0].close = 108.;
488        let fills = engine.update(&mut strategy, &bars, &[]);
489        assert_eq!(fills.len(), 1);
490        // A market order created at 106 fills at the next observed tick, 108.
491        assert_eq!(
492            (fills[0].price, fills[0].quantity, fills[0].position),
493            (108., 2., 2.)
494        );
495        assert_eq!(
496            (&*fills[0].order_id, fills[0].side, fills[0].time),
497            ("Long", OrderSide::Buy, bars[0].time)
498        );
499        bars.push(bar(1, 108.));
500        assert!(engine.update(&mut strategy, &bars, &[]).is_empty());
501        // Unseen high 120 never closes the live position; its close equals the entry at 108.
502        let report = engine.report().unwrap();
503        assert_eq!(report.equity.last().unwrap().1, 10_000.);
504        assert!(report.trades[0].exit.is_none());
505        bars[1].high = 112.;
506        bars[1].close = 112.;
507        let fills = engine.update(&mut strategy, &bars, &[]);
508        assert_eq!(fills.len(), 1);
509        assert_eq!(
510            (
511                fills[0].sequence,
512                fills[0].price,
513                fills[0].quantity,
514                fills[0].position
515            ),
516            (2, 110., 2., 0.)
517        );
518        assert_eq!(fills[0].side, OrderSide::Sell);
519        assert_eq!(
520            engine.report().unwrap().trades[0]
521                .exit
522                .as_ref()
523                .unwrap()
524                .profit,
525            4.
526        );
527        // A historical rebuild must not announce the observed forming-bar executions again.
528        bars[0].high = 121.;
529        assert!(engine.update(&mut strategy, &bars, &[]).is_empty());
530    }
531
532    #[test]
533    #[cfg(feature = "alert-engine")]
534    fn strategy_alerts_deliver_each_fill_once_even_at_the_same_timestamp() {
535        use crate::model::{Alert, AlertCondition};
536        use crate::services::{AlertEngine, AlertEvent};
537        use std::sync::{Arc, Mutex};
538        struct Reverse;
539        impl Strategy for Reverse {
540            fn on_bar(&mut self, context: &mut StrategyContext<'_>) {
541                match context.bars().len() {
542                    1 => {
543                        context.entry("A", OrderSide::Buy, 2., OrderType::Market);
544                        context.entry("B", OrderSide::Buy, 2., OrderType::Market);
545                    }
546                    2 => context.entry("Reverse", OrderSide::Sell, 3., OrderType::Market),
547                    3 => context.exit("Flat", None, None, None),
548                    _ => {}
549                }
550            }
551        }
552        let delivered = Arc::new(Mutex::new(Vec::new()));
553        let sink = delivered.clone();
554        let mut alert = Alert::new("SYM", AlertCondition::StrategyFills("run-1".into()));
555        alert.timeframe = Some(Timeframe::Min1);
556        let alerts = AlertEngine::new()
557            .with_alerts(vec![alert])
558            .on_trigger(move |_, event| {
559                sink.lock().unwrap().push(event.clone());
560            });
561        let mut engine = Backtest::new("Run", "SYM", Timeframe::Min1, 10_000.);
562        engine.settings.pyramiding = 2;
563        let bars: Vec<_> = [187.5, 187.5, 180., 175., 175.]
564            .into_iter()
565            .enumerate()
566            .map(|(i, p)| bar(i as i64, p))
567            .collect();
568        let mut all = vec![];
569        for count in 1..=bars.len() {
570            let fills = engine.update(&mut Reverse, &bars[..count], &[]);
571            alerts.strategy_fills(
572                "other-run",
573                "SYM",
574                Timeframe::Min1,
575                &fills,
576                bars[count - 1].time,
577            );
578            alerts.strategy_fills(
579                "run-1",
580                "OTHER",
581                Timeframe::Min1,
582                &fills,
583                bars[count - 1].time,
584            );
585            alerts.strategy_fills(
586                "run-1",
587                "SYM",
588                Timeframe::Hour1,
589                &fills,
590                bars[count - 1].time,
591            );
592            assert_eq!(
593                delivered.lock().unwrap().len(),
594                all.len(),
595                "other subjects do not notify"
596            );
597            alerts.strategy_fills(
598                "run-1",
599                "SYM",
600                Timeframe::Min1,
601                &fills,
602                bars[count - 1].time,
603            );
604            alerts.strategy_fills(
605                "run-1",
606                "SYM",
607                Timeframe::Min1,
608                &fills,
609                bars[count - 1].time,
610            );
611            all.extend(fills);
612        }
613        assert_eq!(
614            all.iter()
615                .map(|fill| (fill.side, fill.price, fill.quantity, fill.position))
616                .collect::<Vec<_>>(),
617            [
618                (OrderSide::Buy, 187.5, 2., 2.),
619                (OrderSide::Buy, 187.5, 2., 4.),
620                (OrderSide::Sell, 180., 7., -3.),
621                (OrderSide::Buy, 175., 3., 0.),
622            ]
623        );
624        let events = delivered.lock().unwrap();
625        assert_eq!(events.len(), 4);
626        for (event, fill) in events.iter().zip(&all) {
627            let AlertEvent::TriggeredForSymbol {
628                time,
629                price,
630                message,
631                ..
632            } = event
633            else {
634                panic!("fill trigger");
635            };
636            assert_eq!((*time, *price), (fill.time, Some(fill.price)));
637            assert!(!message.is_empty());
638        }
639        let AlertEvent::TriggeredForSymbol { message, .. } = &events[1] else {
640            panic!("fill trigger");
641        };
642        assert_eq!(message, "Buy 2 at 187.50, position 4");
643        drop(events);
644        // Saving edits and rearming keep the execution watermark for the same run.
645        use crate::data::inbox::Inbox;
646        use crate::services::Alerts;
647        let inbox = Arc::new(Inbox::default());
648        for status in [
649            crate::model::AlertStatus::Disabled,
650            crate::model::AlertStatus::Active,
651        ] {
652            let mut edited = alerts.alerts()[0].clone();
653            edited.name = "Renamed".into();
654            edited.status = status;
655            alerts.save(edited, inbox.reply(0));
656            alerts.strategy_fills("run-1", "SYM", Timeframe::Min1, &all, bars[4].time);
657            assert_eq!(delivered.lock().unwrap().len(), 4);
658        }
659        let mut next = all[0].clone();
660        next.sequence = 5;
661        alerts.strategy_fills("run-1", "SYM", Timeframe::Min1, &[next], bars[4].time);
662        assert_eq!(delivered.lock().unwrap().len(), 5);
663        // Fill alerts obey the same lifecycle rules, without needing a price subscription.
664        use crate::model::{
665            AlertFrequency, AlertStatus, NotificationSchedule, NotificationWindow, TimezoneId,
666        };
667        type Edit = fn(&mut Alert);
668        for (edit, count) in [
669            (
670                (|alert: &mut Alert| alert.frequency = AlertFrequency::Once) as Edit,
671                1,
672            ),
673            (|alert| alert.status = AlertStatus::Disabled, 0),
674            (|alert| alert.expires = Some(DateTime::UNIX_EPOCH), 0),
675            (
676                |alert| {
677                    alert.schedule = Some(Box::new(NotificationSchedule {
678                        timezone: TimezoneId::utc(),
679                        days: vec![chrono::Weekday::Thu],
680                        windows: vec![NotificationWindow {
681                            start: chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
682                            end: chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap(),
683                        }],
684                    }))
685                },
686                0,
687            ),
688        ] {
689            let mut alert = Alert::new("SYM", AlertCondition::StrategyFills("run-1".into()));
690            edit(&mut alert);
691            let delivered = Arc::new(Mutex::new(Vec::new()));
692            let sink = delivered.clone();
693            let engine = AlertEngine::new()
694                .with_alerts(vec![alert])
695                .on_trigger(move |_, event| {
696                    sink.lock().unwrap().push(event.clone());
697                });
698            assert!(
699                engine
700                    .subjects(engine.alerts()[0].id.as_ref().unwrap())
701                    .is_empty()
702            );
703            engine.strategy_fills("run-1", "SYM", Timeframe::Min1, &all, bars[4].time);
704            assert_eq!(delivered.lock().unwrap().len(), count);
705            // Opening the schedule must not repeat fills observed outside it.
706            engine.strategy_fills(
707                "run-1",
708                "SYM",
709                Timeframe::Min1,
710                &all,
711                DateTime::UNIX_EPOCH + TimeDelta::hours(12),
712            );
713            assert_eq!(delivered.lock().unwrap().len(), count);
714        }
715        for (message, expected) in [
716            ("", "Buy 2 at 0.00025, position 2"),
717            ("{{symbol}} {{value}}", "SYM 0.00025"),
718        ] {
719            let mut alert = Alert::new("SYM", AlertCondition::StrategyFills("run-1".into()));
720            alert.message = message.into();
721            let delivered = Arc::new(Mutex::new(Vec::new()));
722            let sink = delivered.clone();
723            let engine = AlertEngine::new()
724                .with_alerts(vec![alert])
725                .on_trigger(move |_, event| {
726                    sink.lock().unwrap().push(event.clone());
727                });
728            let mut fill = all[0].clone();
729            fill.price = 0.00025;
730            engine.strategy_fills("run-1", "SYM", Timeframe::Min1, &[fill], bars[4].time);
731            let events = delivered.lock().unwrap();
732            let [AlertEvent::TriggeredForSymbol { message, .. }] = events.as_slice() else {
733                panic!("one fill");
734            };
735            assert_eq!(message, expected);
736        }
737    }
738}