1use std::num::NonZeroU64;
19
20use jiff::{SignedDuration, Span, Zoned};
21use nautilus_core::{UnixNanos, datetime::try_datetime_to_unix_nanos};
22
23use crate::timer::{TimerInterval, TimerSchedule};
24
25#[derive(Debug, Clone)]
32pub struct CalendarSchedule {
33 origin: Zoned,
34 span: Span,
35 start_time_ns: UnixNanos,
36}
37
38impl CalendarSchedule {
39 pub fn new(origin: &Zoned, span: Span) -> anyhow::Result<Self> {
50 anyhow::ensure!(
51 span.is_positive(),
52 "Calendar interval {span} must be positive"
53 );
54
55 let start_time_ns = try_datetime_to_unix_nanos(origin.timestamp())?;
56
57 Ok(Self {
58 origin: origin.clone(),
59 span,
60 start_time_ns,
61 })
62 }
63
64 fn scheduled_time(&self, index: u64) -> Option<UnixNanos> {
65 let offset = self.span.checked_mul(i64::try_from(index).ok()?).ok()?;
66 let scheduled = self.origin.checked_add(offset).ok()?;
67 try_datetime_to_unix_nanos(scheduled.timestamp()).ok()
68 }
69}
70
71impl TimerSchedule for CalendarSchedule {
72 fn start_time_ns(&self) -> UnixNanos {
73 self.start_time_ns
74 }
75
76 fn next_after(&self, time_ns: UnixNanos, index: &mut u64) -> Option<UnixNanos> {
77 if let Ok(duration) = SignedDuration::try_from(self.span) {
78 let interval_ns = NonZeroU64::new(u64::try_from(duration.as_nanos()).ok()?)?;
79 return TimerInterval::Fixed(interval_ns).next_time_after(
80 self.start_time_ns,
81 time_ns,
82 index,
83 );
84 }
85
86 loop {
87 *index = index.checked_add(1)?;
88 let scheduled_time_ns = self.scheduled_time(*index)?;
89 if scheduled_time_ns > time_ns {
90 return Some(scheduled_time_ns);
91 }
92 }
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use jiff::{Span, Timestamp, Zoned};
99 use nautilus_core::{UnixNanos, datetime::get_timezone};
100 use rstest::rstest;
101
102 use super::CalendarSchedule;
103 use crate::timer::TimerSchedule;
104
105 fn utc_nanos(time: &str) -> UnixNanos {
106 UnixNanos::from(time.parse::<Timestamp>().unwrap())
107 }
108
109 #[rstest]
110 fn test_calendar_schedule_next_time_after_skips_to_local_grid() {
111 let interval = CalendarSchedule::new(
112 &"2026-10-30T09:30:00[America/New_York]"
113 .parse::<Zoned>()
114 .unwrap(),
115 Span::new().days(1),
116 )
117 .unwrap();
118
119 let mut index = 0;
120
121 let next_time_ns = interval.next_after(utc_nanos("2026-11-01T20:00:00Z"), &mut index);
122
123 assert_eq!(next_time_ns, Some(utc_nanos("2026-11-02T14:30:00Z")));
124 assert_eq!(index, 3);
125 }
126
127 #[rstest]
128 #[case::nanosecond(Span::new().nanoseconds(1), 10_000_000_000, 10_000_000_001, 10_000_000_001)]
129 #[case::second(Span::new().seconds(1), 10_000_000_000, 11_000_000_000, 11)]
130 fn test_calendar_schedule_time_only_skips_to_grid(
131 #[case] span: Span,
132 #[case] time_ns: u64,
133 #[case] expected: u64,
134 #[case] expected_index: u64,
135 ) {
136 let interval = CalendarSchedule::new(
137 &Timestamp::UNIX_EPOCH.to_zoned(get_timezone("America/New_York").unwrap()),
138 span,
139 )
140 .unwrap();
141
142 let mut index = 0;
143
144 let next_time_ns = interval.next_after(UnixNanos::from(time_ns), &mut index);
145
146 assert_eq!(next_time_ns, Some(UnixNanos::from(expected)));
147 assert_eq!(index, expected_index);
148 }
149
150 #[rstest]
151 #[case::fall_back_keeps_local_time(
152 "2026-10-30T09:30:00[America/New_York]",
153 "1d",
154 ["2026-10-30T13:30:00Z", "2026-10-31T13:30:00Z", "2026-11-01T14:30:00Z", "2026-11-02T14:30:00Z"],
155 )]
156 #[case::spring_forward_keeps_local_time(
157 "2026-03-06T09:30:00[America/New_York]",
158 "1d",
159 ["2026-03-06T14:30:00Z", "2026-03-07T14:30:00Z", "2026-03-08T13:30:00Z", "2026-03-09T13:30:00Z"],
160 )]
161 #[case::spring_forward_gap_does_not_shift_later_days(
162 "2026-03-07T02:30:00[America/New_York]",
163 "1d",
164 ["2026-03-07T07:30:00Z", "2026-03-08T07:30:00Z", "2026-03-09T06:30:00Z", "2026-03-10T06:30:00Z"],
165 )]
166 #[case::month_end_clamp_does_not_shift_later_months(
167 "2026-01-31T00:00:00[UTC]",
168 "1mo",
169 ["2026-01-31T00:00:00Z", "2026-02-28T00:00:00Z", "2026-03-31T00:00:00Z", "2026-04-30T00:00:00Z"],
170 )]
171 #[case::hours_stay_exact_across_fall_back(
172 "2026-11-01T00:00:00[America/New_York]",
173 "12h",
174 ["2026-11-01T04:00:00Z", "2026-11-01T16:00:00Z", "2026-11-02T04:00:00Z", "2026-11-02T16:00:00Z"],
175 )]
176 fn test_calendar_schedule_nominal_origin(
177 #[case] start: &str,
178 #[case] span: &str,
179 #[case] expected: [&str; 4],
180 ) {
181 let start = start.parse::<Zoned>().unwrap();
182 let schedule = CalendarSchedule::new(&start, span.parse::<Span>().unwrap()).unwrap();
183 let mut index = 0;
184 let mut actual = vec![schedule.start_time_ns()];
185 for _ in 0..3 {
186 actual.push(
187 schedule
188 .next_after(*actual.last().unwrap(), &mut index)
189 .unwrap(),
190 );
191 }
192
193 assert_eq!(
194 actual,
195 expected.into_iter().map(utc_nanos).collect::<Vec<_>>()
196 );
197 assert_eq!(index, 3);
198 }
199
200 #[rstest]
201 #[case::zero(Span::new(), "Calendar interval PT0S must be positive")]
202 #[case::negative(Span::new().days(-1), "Calendar interval -P1D must be positive")]
203 fn test_calendar_schedule_rejects_non_positive_span(
204 #[case] span: Span,
205 #[case] expected: &str,
206 ) {
207 let error = CalendarSchedule::new(
208 &Timestamp::UNIX_EPOCH.to_zoned(jiff::tz::TimeZone::UTC),
209 span,
210 )
211 .unwrap_err();
212 assert_eq!(error.to_string(), expected);
213 }
214
215 #[rstest]
216 fn test_calendar_schedule_exhausts_unrepresentable_deadline() {
217 let origin = UnixNanos::from(u64::MAX)
218 .to_datetime_utc()
219 .to_zoned(jiff::tz::TimeZone::UTC);
220 let schedule = CalendarSchedule::new(&origin, Span::new().days(1)).unwrap();
221 let mut index = 0;
222 assert_eq!(
223 schedule.next_after(UnixNanos::from(u64::MAX), &mut index),
224 None
225 );
226 }
227}