1use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};
4use serde::{Deserialize, Serialize};
5
6use super::{Bar, Timeframe, TimezoneId};
7
8#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct AlertId(
11 pub String,
13);
14
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
23#[non_exhaustive]
24pub struct Alert {
25 #[serde(default)]
27 pub id: Option<AlertId>,
28 pub symbol: String,
30 #[serde(default)]
32 pub name: String,
33 #[serde(default)]
35 pub watchlist: Option<String>,
36 #[serde(default)]
39 pub timeframe: Option<Timeframe>,
40 #[serde(default)]
43 pub inputs: Option<Box<AlertInputs>>,
44 #[serde(default)]
46 pub source: AlertSource,
47 #[serde(default)]
49 pub additional_conditions: Vec<AlertRule>,
50 pub condition: AlertCondition,
52 #[serde(default)]
54 pub frequency: AlertFrequency,
55 #[serde(default)]
57 pub expires: Option<DateTime<Utc>>,
58 #[serde(default)]
61 pub schedule: Option<Box<NotificationSchedule>>,
62 #[serde(default)]
65 pub message: String,
66 #[serde(default)]
68 pub status: AlertStatus,
69}
70
71#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
74pub struct AlertInputs {
75 pub timezone: Option<TimezoneId>,
77 pub calendar: Option<AlertCalendar>,
79 pub sources: Vec<(AlertSource, String)>,
81 pub extended_hours: bool,
83 pub conversion: Option<String>,
85 pub count: usize,
87 pub start: Option<DateTime<Utc>>,
89 pub visible: Option<(DateTime<Utc>, DateTime<Utc>)>,
91 pub logarithmic: bool,
93 pub studies: Vec<AlertStudy>,
95}
96
97impl Default for AlertInputs {
98 fn default() -> Self {
99 Self {
100 timezone: Some(TimezoneId::utc()),
101 calendar: None,
102 sources: Vec::new(),
103 extended_hours: false,
104 conversion: None,
105 count: 1000,
106 start: None,
107 visible: None,
108 logarithmic: false,
109 studies: Vec::new(),
110 }
111 }
112}
113
114impl AlertInputs {
115 pub fn add_sources_from(&mut self, other: &Self) {
118 for study in &other.studies {
119 if !self.studies.iter().any(|saved| saved.id == study.id) {
120 self.studies.push(study.clone());
121 }
122 }
123 for (source, name) in &other.sources {
124 if !self.sources.iter().any(|(saved, _)| saved == source) {
125 self.sources.push((source.clone(), name.clone()));
126 }
127 }
128 }
129}
130
131#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
133pub struct AlertCalendar {
134 pub symbol: String,
136 pub timezone: Option<TimezoneId>,
138 pub session: Option<super::Session>,
140}
141
142#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
144pub struct AlertStudy {
145 pub id: usize,
147 pub name: String,
149 pub inputs: Vec<(String, super::InputValue)>,
151}
152
153impl Alert {
154 pub fn new(symbol: impl Into<String>, condition: AlertCondition) -> Self {
157 Self {
158 id: None,
159 symbol: symbol.into(),
160 name: String::new(),
161 watchlist: None,
162 timeframe: None,
163 inputs: None,
164 source: AlertSource::Price,
165 additional_conditions: Vec::new(),
166 frequency: if matches!(condition, AlertCondition::StrategyFills(_)) {
167 AlertFrequency::EveryTime
168 } else {
169 AlertFrequency::default()
170 },
171 condition,
172 expires: None,
173 schedule: None,
174 message: String::new(),
175 status: AlertStatus::default(),
176 }
177 }
178}
179
180impl Alert {
181 pub fn sources(&self) -> impl Iterator<Item = &AlertSource> {
183 std::iter::once((&self.source, &self.condition))
184 .chain(
185 self.additional_conditions
186 .iter()
187 .map(|rule| (&rule.source, &rule.condition)),
188 )
189 .flat_map(|(source, condition)| {
190 std::iter::once(source).chain(condition.partners().into_iter().flatten())
191 })
192 }
193
194 pub fn expand_message(
215 &self,
216 symbol: &str,
217 timeframe: Timeframe,
218 bar: &Bar,
219 value: impl Fn(&AlertSource) -> Option<f64>,
220 ) -> String {
221 let number = |number: Option<f64>| {
222 number
223 .filter(|number| number.is_finite())
224 .map(|number| number.to_string())
225 .unwrap_or_default()
226 };
227 let field = |name: &str| -> Option<String> {
228 Some(match name {
229 "symbol" => symbol.to_owned(),
230 "timeframe" => timeframe.to_string(),
231 "time" => bar.time.to_rfc3339(),
232 "open" => number(Some(bar.open)),
233 "high" => number(Some(bar.high)),
234 "low" => number(Some(bar.low)),
235 "close" => number(Some(bar.close)),
236 "volume" => number(Some(bar.volume)),
237 "value" => number(value(&self.source)),
238 _ => {
239 let output = name.strip_prefix("output_")?.parse().ok()?;
240 match &self.source {
241 AlertSource::Study { id, .. } => number(value(&AlertSource::Study {
242 id: id.clone(),
243 output,
244 })),
245 _ => String::new(),
246 }
247 }
248 })
249 };
250 let mut text = String::new();
251 let mut rest = self.message.as_str();
252 while let Some(start) = rest.find("{{")
253 && let Some(length) = rest[start + 2..].find("}}")
254 {
255 let end = start + 2 + length;
256 text.push_str(&rest[..start]);
257 match field(&rest[start + 2..end]) {
258 Some(filled) => text.push_str(&filled),
259 None => text.push_str(&rest[start..end + 2]),
260 }
261 rest = &rest[end + 2..];
262 }
263 text.push_str(rest);
264 text
265 }
266}
267
268#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
272#[non_exhaustive]
273pub enum AlertCondition {
274 StrategyFills(String),
278 Crossing(f64),
281 CrossingUp(f64),
283 CrossingDown(f64),
285 Above(f64),
287 Below(f64),
289 EnteringChannel {
291 lower: f64,
293 upper: f64,
295 },
296 ExitingChannel {
298 lower: f64,
300 upper: f64,
302 },
303 InsideChannel {
306 lower: f64,
308 upper: f64,
310 },
311 OutsideChannel {
314 lower: f64,
316 upper: f64,
318 },
319 MovingBy {
321 value: f64,
323 bars: u32,
325 },
326 MovingByPercent {
329 percent: f64,
331 bars: u32,
333 },
334 Compare {
336 other: AlertSource,
338 comparison: AlertComparison,
340 },
341 Channel {
347 boundaries: [AlertSource; 2],
349 comparison: AlertChannelComparison,
351 },
352 VolumeAboveAverage {
354 multiplier: f64,
356 bars: u32,
358 },
359}
360
361#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
363pub enum AlertSource {
364 #[default]
366 Price,
367 Study {
369 id: String,
371 output: usize,
373 },
374 Drawing(String),
376 Volume,
378}
379
380#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
382pub enum AlertComparison {
383 Crossing,
385 CrossingUp,
387 CrossingDown,
389 Above,
391 Below,
393}
394
395#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
397pub enum AlertChannelComparison {
398 Entering,
400 Exiting,
402 Inside,
404 Outside,
406}
407
408impl std::fmt::Display for AlertChannelComparison {
409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410 f.write_str(match self {
411 Self::Entering => "Entering channel",
412 Self::Exiting => "Exiting channel",
413 Self::Inside => "Inside channel",
414 Self::Outside => "Outside channel",
415 })
416 }
417}
418
419#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
421pub struct AlertRule {
422 pub source: AlertSource,
424 pub condition: AlertCondition,
426}
427
428impl AlertSource {
429 fn is_valid(&self) -> bool {
430 match self {
431 Self::Study { id, .. } | Self::Drawing(id) => !id.trim().is_empty(),
432 _ => true,
433 }
434 }
435}
436
437impl AlertCondition {
438 pub(crate) fn partners(&self) -> [Option<&AlertSource>; 2] {
440 match self {
441 Self::Compare { other, .. } => [Some(other), None],
442 Self::Channel {
443 boundaries: [a, b], ..
444 } => [Some(a), Some(b)],
445 _ => [None, None],
446 }
447 }
448
449 pub fn is_valid(&self) -> bool {
451 match self {
452 Self::StrategyFills(run) => !run.trim().is_empty(),
453 Self::Crossing(v)
454 | Self::CrossingUp(v)
455 | Self::CrossingDown(v)
456 | Self::Above(v)
457 | Self::Below(v) => v.is_finite(),
458 Self::EnteringChannel { lower, upper }
459 | Self::ExitingChannel { lower, upper }
460 | Self::InsideChannel { lower, upper }
461 | Self::OutsideChannel { lower, upper } => {
462 lower.is_finite() && upper.is_finite() && lower < upper
463 }
464 Self::MovingBy { value, bars } => value.is_finite() && *value != 0.0 && *bars > 0,
465 Self::MovingByPercent { percent, bars } => {
466 percent.is_finite() && *percent != 0.0 && *bars > 0
467 }
468 Self::Compare { other, .. } => other.is_valid(),
469 Self::Channel {
470 boundaries: [a, b], ..
471 } => a != b && a.is_valid() && b.is_valid(),
472 Self::VolumeAboveAverage { multiplier, bars } => {
473 multiplier.is_finite() && *multiplier > 0.0 && *bars > 0
474 }
475 }
476 }
477
478 pub fn level(&self) -> Option<f64> {
480 match self {
481 Self::Crossing(v)
482 | Self::CrossingUp(v)
483 | Self::CrossingDown(v)
484 | Self::Above(v)
485 | Self::Below(v) => Some(*v),
486 _ => None,
487 }
488 }
489}
490
491impl std::fmt::Display for AlertCondition {
492 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493 match self {
494 Self::StrategyFills(run) => write!(f, "Fills of strategy {run}"),
495 Self::Crossing(v) => write!(f, "Crossing {v}"),
496 Self::CrossingUp(v) => write!(f, "Crossing up {v}"),
497 Self::CrossingDown(v) => write!(f, "Crossing down {v}"),
498 Self::Above(v) => write!(f, "Greater than {v}"),
499 Self::Below(v) => write!(f, "Less than {v}"),
500 Self::EnteringChannel { lower, upper } => write!(f, "Entering {lower} to {upper}"),
501 Self::ExitingChannel { lower, upper } => write!(f, "Exiting {lower} to {upper}"),
502 Self::InsideChannel { lower, upper } => write!(f, "Inside {lower} to {upper}"),
503 Self::OutsideChannel { lower, upper } => write!(f, "Outside {lower} to {upper}"),
504 Self::MovingBy { value, bars } => write!(f, "Moving by {value} over {bars} bars"),
505 Self::MovingByPercent { percent, bars } => {
506 write!(f, "Moving by {percent}% over {bars} bars")
507 }
508 Self::Compare { comparison, .. } => write!(f, "{} another output", comparison),
509 Self::Channel { comparison, .. } => write!(f, "{comparison}"),
510 Self::VolumeAboveAverage { multiplier, bars } => write!(
511 f,
512 "Volume above {multiplier} times the preceding {bars}-bar mean"
513 ),
514 }
515 }
516}
517
518impl Alert {
519 pub fn is_valid(&self) -> bool {
521 (!self.symbol.trim().is_empty()
522 || self
523 .watchlist
524 .as_ref()
525 .is_some_and(|id| !id.trim().is_empty()))
526 && self
527 .watchlist
528 .as_ref()
529 .is_none_or(|id| !id.trim().is_empty())
530 && (!matches!(self.condition, AlertCondition::StrategyFills(_))
531 || (self.source == AlertSource::Price
532 && self.additional_conditions.is_empty()
533 && matches!(
534 self.frequency,
535 AlertFrequency::EveryTime | AlertFrequency::Once
536 )))
537 && self
538 .additional_conditions
539 .iter()
540 .all(|rule| !matches!(rule.condition, AlertCondition::StrategyFills(_)))
541 && self.source.is_valid()
542 && self.condition.is_valid()
543 && self
544 .additional_conditions
545 .iter()
546 .all(|rule| rule.source.is_valid() && rule.condition.is_valid())
547 && self
548 .schedule
549 .as_ref()
550 .is_none_or(|schedule| schedule.is_valid())
551 }
552}
553
554#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
558pub struct NotificationSchedule {
559 pub timezone: TimezoneId,
561 pub days: Vec<Weekday>,
563 pub windows: Vec<NotificationWindow>,
565}
566
567#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
570pub struct NotificationWindow {
571 pub start: NaiveTime,
573 pub end: NaiveTime,
575}
576
577impl NotificationSchedule {
578 pub fn is_valid(&self) -> bool {
580 !self.days.is_empty()
581 && !self.windows.is_empty()
582 && self.windows.iter().all(|w| w.start != w.end)
583 }
584
585 pub fn allows(&self, at: DateTime<Utc>) -> bool {
587 let local = at.with_timezone(&self.timezone);
588 let (day, time) = (local.weekday(), local.time());
589 self.windows.iter().any(|w| {
590 if w.start < w.end {
591 self.days.contains(&day) && (w.start..w.end).contains(&time)
592 } else {
593 (self.days.contains(&day) && time >= w.start)
594 || (self.days.contains(&day.pred()) && time < w.end)
595 }
596 })
597 }
598}
599
600#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
602#[non_exhaustive]
603pub enum AlertFrequency {
604 #[default]
606 Once,
607 EveryTime,
610 OncePerBar,
612 OncePerBarClose,
614 OncePerMinute,
617}
618
619impl std::fmt::Display for AlertFrequency {
620 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621 f.write_str(match self {
622 Self::Once => "Only once",
623 Self::EveryTime => "Every time",
624 Self::OncePerBar => "Once per bar",
625 Self::OncePerBarClose => "Once per bar close",
626 Self::OncePerMinute => "Once per minute",
627 })
628 }
629}
630
631impl std::fmt::Display for AlertComparison {
632 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
633 f.write_str(match self {
634 Self::Crossing => "Crossing",
635 Self::CrossingUp => "Crossing up",
636 Self::CrossingDown => "Crossing down",
637 Self::Above => "Greater than",
638 Self::Below => "Less than",
639 })
640 }
641}
642
643#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
645#[non_exhaustive]
646pub enum AlertStatus {
647 #[default]
649 Active,
650 Triggered,
652 Disabled,
654 Expired,
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 #[test]
663 fn stored_named_compound_alert_round_trips() {
664 let stored = serde_json::json!({
665 "symbol": "AAPL", "name": "Momentum", "message": "Check momentum",
666 "watchlist": "technology", "source": {"Study": {"id": "rsi", "output": 0}},
667 "condition": {"Above": 70.0}, "frequency": "OncePerBarClose",
668 "additional_conditions": [{"source": "Price", "condition": {"EnteringChannel": {"lower": 100.0, "upper": 110.0}}}]
669 });
670 let alert: Alert = serde_json::from_value(stored.clone()).unwrap();
671 let saved = serde_json::to_value(alert).unwrap();
672 for (key, value) in stored.as_object().unwrap() {
673 assert_eq!(saved.get(key), Some(value), "{key}");
674 }
675 }
676
677 #[test]
678 fn messages_fill_placeholders_from_the_evaluated_bar() {
679 let time = DateTime::from_timestamp(1_704_067_200, 0).unwrap();
680 let bar = Bar::new(time, 99.0, 102.5, f64::NAN, 101.5, 1000.0);
681 let study = AlertSource::Study {
682 id: "3".into(),
683 output: 0,
684 };
685 let value = |source: &AlertSource| match source {
687 AlertSource::Study { id, output: 0 } if id == "3" => Some(70.25),
688 AlertSource::Study { id, output: 1 } if id == "3" => Some(3.0),
689 AlertSource::Price => Some(101.5),
690 _ => None,
691 };
692 for (message, source, expected) in [
693 (
694 "{{symbol}} closed at {{close}}",
695 AlertSource::Price,
696 "AAPL closed at 101.5",
697 ),
698 (
699 "{{timeframe}} {{time}} {{open}} {{high}} [{{low}}] {{volume}}",
700 AlertSource::Price,
701 "5min 2024-01-01T00:00:00+00:00 99 102.5 [] 1000",
702 ),
703 ("{{value}}/{{output_1}}", study.clone(), "70.25/3"),
704 ("{{output_2}}.", study.clone(), "."),
705 ("{{value}}{{output_1}}", AlertSource::Price, "101.5"),
706 (
707 "{{ close }} {{nope}} {{output_x}} {{close",
708 AlertSource::Price,
709 "{{ close }} {{nope}} {{output_x}} {{close",
710 ),
711 ("{{{{close}}}}", AlertSource::Price, "{{{{close}}}}"),
712 ] {
713 let mut alert = Alert::new("AAPL", AlertCondition::Above(100.0));
714 alert.source = source;
715 alert.message = message.into();
716 assert_eq!(
717 alert.expand_message("AAPL", Timeframe::Min5, &bar, value),
718 expected,
719 "{message}"
720 );
721 }
722 }
723
724 #[test]
725 fn saved_alerts_require_finite_levels_ordered_channels_and_positive_lookbacks() {
726 type Edit = fn(&mut Alert);
727 let rows: [(Edit, bool); 22] = [
728 (
729 |a| a.condition = AlertCondition::StrategyFills("run".into()),
730 true,
731 ),
732 (
733 |a| a.condition = AlertCondition::StrategyFills(" ".into()),
734 false,
735 ),
736 (
737 |a| {
738 a.condition = AlertCondition::StrategyFills("run".into());
739 a.frequency = AlertFrequency::OncePerBar;
740 },
741 false,
742 ),
743 (
744 |a| {
745 a.additional_conditions.push(AlertRule {
746 source: AlertSource::Price,
747 condition: AlertCondition::StrategyFills("run".into()),
748 });
749 },
750 false,
751 ),
752 (|a| a.condition = AlertCondition::Crossing(f64::NAN), false),
753 (
754 |a| a.condition = AlertCondition::Above(f64::INFINITY),
755 false,
756 ),
757 (|a| a.condition = AlertCondition::Below(-12.5), true),
758 (
759 |a| {
760 a.condition = AlertCondition::InsideChannel {
761 lower: 1.0,
762 upper: 2.0,
763 }
764 },
765 true,
766 ),
767 (
768 |a| {
769 a.condition = AlertCondition::OutsideChannel {
770 lower: 2.0,
771 upper: 2.0,
772 }
773 },
774 false,
775 ),
776 (
777 |a| {
778 a.schedule = Some(Box::new(NotificationSchedule {
779 timezone: TimezoneId::utc(),
780 days: Vec::new(),
781 windows: vec![NotificationWindow {
782 start: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
783 end: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
784 }],
785 }))
786 },
787 false,
788 ),
789 (
790 |a| {
791 a.schedule = Some(Box::new(NotificationSchedule {
792 timezone: TimezoneId::utc(),
793 days: vec![Weekday::Mon],
794 windows: vec![NotificationWindow {
795 start: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
796 end: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
797 }],
798 }))
799 },
800 false,
801 ),
802 (
803 |a| {
804 a.condition = AlertCondition::EnteringChannel {
805 lower: 2.0,
806 upper: 1.0,
807 }
808 },
809 false,
810 ),
811 (
812 |a| {
813 a.condition = AlertCondition::ExitingChannel {
814 lower: 1.0,
815 upper: 1.0,
816 }
817 },
818 false,
819 ),
820 (
821 |a| {
822 a.condition = AlertCondition::MovingBy {
823 value: 1.0,
824 bars: 0,
825 }
826 },
827 false,
828 ),
829 (
830 |a| {
831 a.condition = AlertCondition::MovingByPercent {
832 percent: 0.0,
833 bars: 1,
834 }
835 },
836 false,
837 ),
838 (
839 |a| {
840 a.condition = AlertCondition::VolumeAboveAverage {
841 multiplier: -2.0,
842 bars: 20,
843 }
844 },
845 false,
846 ),
847 (
848 |a| {
849 a.condition = AlertCondition::VolumeAboveAverage {
850 multiplier: 2.0,
851 bars: 0,
852 }
853 },
854 false,
855 ),
856 (
857 |a| {
858 a.source = AlertSource::Study {
859 id: " ".into(),
860 output: 0,
861 }
862 },
863 false,
864 ),
865 (|a| a.symbol.clear(), false),
866 (
867 |a| {
868 a.symbol.clear();
869 a.watchlist = Some("technology".into());
870 },
871 true,
872 ),
873 (|a| a.watchlist = Some(" ".into()), false),
874 (
875 |a| {
876 a.additional_conditions.push(AlertRule {
877 source: AlertSource::Drawing(String::new()),
878 condition: AlertCondition::Above(1.0),
879 })
880 },
881 false,
882 ),
883 ];
884 for (index, (edit, valid)) in rows.into_iter().enumerate() {
885 let mut alert = Alert::new("SAMPLE", AlertCondition::Crossing(1.0));
886 edit(&mut alert);
887 assert_eq!(alert.is_valid(), valid, "row {index}");
888 }
889 }
890
891 #[test]
892 fn a_minimal_stored_alert_loads() {
893 let stored = r#"{"symbol":"AAPL","condition":{"CrossingUp":101.5}}"#;
894 let alert: Alert = serde_json::from_str(stored).unwrap();
895 assert_eq!(
896 alert,
897 Alert {
898 id: None,
899 name: String::new(),
900 source: AlertSource::Price,
901 watchlist: None,
902 timeframe: None,
903 inputs: None,
904 additional_conditions: Vec::new(),
905 symbol: "AAPL".into(),
906 condition: AlertCondition::CrossingUp(101.5),
907 frequency: AlertFrequency::Once,
908 expires: None,
909 schedule: None,
910 message: String::new(),
911 status: AlertStatus::Active,
912 }
913 );
914 }
915
916 #[cfg(feature = "timezones")]
917 #[test]
918 fn a_stored_schedule_round_trips_and_a_window_may_cross_midnight() {
919 let stored = serde_json::json!({
920 "symbol": "AAPL", "condition": {"InsideChannel": {"lower": 1.0, "upper": 2.0}},
921 "frequency": "OncePerMinute",
922 "schedule": {"timezone": "America/New_York", "days": ["Fri"],
923 "windows": [{"start": "22:00:00", "end": "02:00:00"}]}
924 });
925 let alert: Alert = serde_json::from_value(stored.clone()).unwrap();
926 assert_eq!(
927 serde_json::to_value(&alert).unwrap()["schedule"],
928 stored["schedule"]
929 );
930 let schedule = alert.schedule.unwrap();
931 let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
932 assert!(schedule.allows(at("2026-03-07T03:30:00Z")));
935 assert!(schedule.allows(at("2026-03-07T06:59:00Z")));
936 assert!(!schedule.allows(at("2026-03-07T07:00:00Z")));
937 assert!(!schedule.allows(at("2026-03-06T03:30:00Z")));
939 assert!(schedule.allows(at("2026-03-14T02:30:00Z")));
941 assert!(schedule.allows(at("2026-03-14T05:59:00Z")));
942 assert!(!schedule.allows(at("2026-03-14T06:00:00Z")));
943 }
944}