1use std::fmt;
51use std::ops::Index;
52
53use crate::calendar::{BusinessDayConvention, Calendar, WeekendsOnly};
54use crate::date::{Date, Period, Unit};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58#[non_exhaustive]
59pub enum DateGeneration {
60 Backward,
62 Forward,
64 Zero,
66 ThirdWednesday,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74#[non_exhaustive]
75pub enum StubConvention {
76 ShortFront,
78 LongFront,
80 ShortBack,
82 LongBack,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum ScheduleError {
90 EmptyRange { effective: Date, termination: Date },
92 NonPositiveTenor(i32),
94 StubDirectionMismatch {
97 rule: DateGeneration,
98 stub: StubConvention,
99 },
100}
101
102impl fmt::Display for ScheduleError {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 match self {
105 Self::EmptyRange {
106 effective,
107 termination,
108 } => write!(
109 f,
110 "termination {termination} must be after effective {effective}"
111 ),
112 Self::NonPositiveTenor(n) => write!(f, "tenor must be positive, got {n}"),
113 Self::StubDirectionMismatch { rule, stub } => {
114 write!(f, "stub {stub:?} is incompatible with rule {rule:?}")
115 }
116 }
117 }
118}
119
120impl std::error::Error for ScheduleError {}
121
122#[must_use]
124pub fn third_wednesday(year: i32, month: u32) -> Date {
125 let first = Date::new(year, month, 1).expect("first of month is valid");
126 let offset = (3 + 7 - first.weekday().number()) % 7;
128 first.add_days(offset as i32 + 14)
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Schedule {
134 dates: Vec<Date>,
135}
136
137impl Schedule {
138 #[must_use]
141 pub fn builder(effective: Date, termination: Date, tenor: Period) -> ScheduleBuilder {
142 ScheduleBuilder::new(effective, termination, tenor)
143 }
144
145 #[must_use]
147 pub fn dates(&self) -> &[Date] {
148 &self.dates
149 }
150
151 #[must_use]
153 pub fn len(&self) -> usize {
154 self.dates.len()
155 }
156
157 #[must_use]
159 pub fn is_empty(&self) -> bool {
160 self.dates.is_empty()
161 }
162
163 #[must_use]
165 pub fn effective_date(&self) -> Date {
166 self.dates[0]
167 }
168
169 #[must_use]
171 pub fn termination_date(&self) -> Date {
172 self.dates[self.dates.len() - 1]
173 }
174
175 pub fn iter(&self) -> std::slice::Iter<'_, Date> {
177 self.dates.iter()
178 }
179}
180
181impl Index<usize> for Schedule {
182 type Output = Date;
183 fn index(&self, i: usize) -> &Date {
184 &self.dates[i]
185 }
186}
187
188impl<'a> IntoIterator for &'a Schedule {
189 type Item = &'a Date;
190 type IntoIter = std::slice::Iter<'a, Date>;
191 fn into_iter(self) -> Self::IntoIter {
192 self.dates.iter()
193 }
194}
195
196pub struct ScheduleBuilder {
200 effective: Date,
201 termination: Date,
202 tenor: Period,
203 calendar: Box<dyn Calendar>,
204 convention: BusinessDayConvention,
205 termination_convention: BusinessDayConvention,
206 end_of_month: bool,
207 rule: DateGeneration,
208 stub: Option<StubConvention>,
209}
210
211impl ScheduleBuilder {
212 fn new(effective: Date, termination: Date, tenor: Period) -> Self {
213 Self {
214 effective,
215 termination,
216 tenor,
217 calendar: Box::new(WeekendsOnly),
218 convention: BusinessDayConvention::ModifiedFollowing,
219 termination_convention: BusinessDayConvention::ModifiedFollowing,
220 end_of_month: false,
221 rule: DateGeneration::Backward,
222 stub: None,
223 }
224 }
225
226 #[must_use]
228 pub fn calendar(mut self, calendar: Box<dyn Calendar>) -> Self {
229 self.calendar = calendar;
230 self
231 }
232
233 #[must_use]
235 pub fn convention(mut self, convention: BusinessDayConvention) -> Self {
236 self.convention = convention;
237 self
238 }
239
240 #[must_use]
242 pub fn termination_convention(mut self, convention: BusinessDayConvention) -> Self {
243 self.termination_convention = convention;
244 self
245 }
246
247 #[must_use]
250 pub fn end_of_month(mut self, eom: bool) -> Self {
251 self.end_of_month = eom;
252 self
253 }
254
255 #[must_use]
257 pub fn rule(mut self, rule: DateGeneration) -> Self {
258 self.rule = rule;
259 self
260 }
261
262 #[must_use]
264 pub fn stub(mut self, stub: StubConvention) -> Self {
265 self.stub = Some(stub);
266 self
267 }
268
269 pub fn build(self) -> Result<Schedule, ScheduleError> {
279 if self.termination <= self.effective {
280 return Err(ScheduleError::EmptyRange {
281 effective: self.effective,
282 termination: self.termination,
283 });
284 }
285
286 if self.rule == DateGeneration::Zero {
288 return Ok(self.adjust_and_finish(vec![self.effective, self.termination]));
289 }
290
291 if self.tenor.num <= 0 {
292 return Err(ScheduleError::NonPositiveTenor(self.tenor.num));
293 }
294
295 let forward = self.rule == DateGeneration::Forward;
296 let stub = self.resolve_stub(forward)?;
297
298 let mut unadjusted = if forward {
299 self.generate_forward(stub)
300 } else {
301 self.generate_backward(stub)
302 };
303
304 if self.rule == DateGeneration::ThirdWednesday {
305 for date in &mut unadjusted {
306 *date = third_wednesday(date.year(), date.month());
307 }
308 }
309
310 Ok(self.adjust_and_finish(unadjusted))
311 }
312
313 fn resolve_stub(&self, forward: bool) -> Result<StubConvention, ScheduleError> {
315 match self.stub {
316 None => Ok(if forward {
317 StubConvention::ShortBack
318 } else {
319 StubConvention::ShortFront
320 }),
321 Some(s) => {
322 let ok = matches!(
323 (forward, s),
324 (true, StubConvention::ShortBack | StubConvention::LongBack)
325 | (
326 false,
327 StubConvention::ShortFront | StubConvention::LongFront
328 )
329 );
330 if ok {
331 Ok(s)
332 } else {
333 Err(ScheduleError::StubDirectionMismatch {
334 rule: self.rule,
335 stub: s,
336 })
337 }
338 }
339 }
340 }
341
342 fn seed(&self, anchor: Date, mult: i32) -> Date {
344 let shifted = anchor.add_period(Period {
345 num: self.tenor.num * mult,
346 unit: self.tenor.unit,
347 });
348 if self.end_of_month
349 && matches!(self.tenor.unit, Unit::Months | Unit::Years)
350 && anchor.is_end_of_month()
351 {
352 shifted.end_of_month()
353 } else {
354 shifted
355 }
356 }
357
358 fn generate_backward(&self, stub: StubConvention) -> Vec<Date> {
360 let mut tmp = Vec::new();
361 let mut i = 0;
362 loop {
363 let d = self.seed(self.termination, -i);
364 if d < self.effective {
365 break;
366 }
367 tmp.push(d);
368 if d == self.effective {
369 break;
370 }
371 i += 1;
372 }
373 let exact = tmp.last() == Some(&self.effective);
375 if !exact {
376 if stub == StubConvention::LongFront && tmp.len() >= 2 {
377 tmp.pop(); }
379 tmp.push(self.effective);
380 }
381 tmp.reverse();
382 tmp
383 }
384
385 fn generate_forward(&self, stub: StubConvention) -> Vec<Date> {
387 let mut tmp = Vec::new();
388 let mut i = 0;
389 loop {
390 let d = self.seed(self.effective, i);
391 if d > self.termination {
392 break;
393 }
394 tmp.push(d);
395 if d == self.termination {
396 break;
397 }
398 i += 1;
399 }
400 let exact = tmp.last() == Some(&self.termination);
402 if !exact {
403 if stub == StubConvention::LongBack && tmp.len() >= 2 {
404 tmp.pop(); }
406 tmp.push(self.termination);
407 }
408 tmp
409 }
410
411 fn adjust_and_finish(&self, unadjusted: Vec<Date>) -> Schedule {
413 let n = unadjusted.len();
414 let mut dates: Vec<Date> = unadjusted
415 .into_iter()
416 .enumerate()
417 .map(|(idx, d)| {
418 let conv = if idx == n - 1 {
419 self.termination_convention
420 } else {
421 self.convention
422 };
423 self.calendar.adjust(d, conv)
424 })
425 .collect();
426 dates.dedup();
427 Schedule { dates }
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::calendar::Brazil;
435
436 fn d(y: i32, m: u32, day: u32) -> Date {
437 Date::new(y, m, day).unwrap()
438 }
439
440 fn unadjusted_builder(eff: Date, term: Date, tenor: Period) -> ScheduleBuilder {
441 Schedule::builder(eff, term, tenor)
442 .convention(BusinessDayConvention::Unadjusted)
443 .termination_convention(BusinessDayConvention::Unadjusted)
444 }
445
446 #[test]
447 fn third_wednesday_known() {
448 assert_eq!(third_wednesday(2025, 3), d(2025, 3, 19));
450 assert_eq!(third_wednesday(2025, 12), d(2025, 12, 17));
452 }
453
454 #[test]
455 fn backward_even_periods_no_stub() {
456 let s = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(6))
457 .build()
458 .unwrap();
459 assert_eq!(s.dates(), &[d(2024, 1, 15), d(2024, 7, 15), d(2025, 1, 15)]);
460 assert_eq!(s.len(), 3);
461 assert_eq!(s.effective_date(), d(2024, 1, 15));
462 assert_eq!(s.termination_date(), d(2025, 1, 15));
463 }
464
465 #[test]
466 fn backward_short_front_stub() {
467 let s = unadjusted_builder(d(2024, 2, 10), d(2026, 1, 15), Period::months(6))
469 .build()
470 .unwrap();
471 assert_eq!(
472 s.dates(),
473 &[
474 d(2024, 2, 10), d(2024, 7, 15),
476 d(2025, 1, 15),
477 d(2025, 7, 15),
478 d(2026, 1, 15),
479 ]
480 );
481 }
482
483 #[test]
484 fn backward_long_front_stub() {
485 let s = unadjusted_builder(d(2024, 2, 10), d(2026, 1, 15), Period::months(6))
486 .stub(StubConvention::LongFront)
487 .build()
488 .unwrap();
489 assert_eq!(
491 s.dates(),
492 &[
493 d(2024, 2, 10),
494 d(2025, 1, 15),
495 d(2025, 7, 15),
496 d(2026, 1, 15),
497 ]
498 );
499 }
500
501 #[test]
502 fn forward_short_back_stub() {
503 let s = unadjusted_builder(d(2024, 1, 15), d(2025, 4, 10), Period::months(6))
505 .rule(DateGeneration::Forward)
506 .build()
507 .unwrap();
508 assert_eq!(
509 s.dates(),
510 &[
511 d(2024, 1, 15),
512 d(2024, 7, 15),
513 d(2025, 1, 15),
514 d(2025, 4, 10), ]
516 );
517 }
518
519 #[test]
520 fn forward_long_back_stub() {
521 let s = unadjusted_builder(d(2024, 1, 15), d(2025, 4, 10), Period::months(6))
522 .rule(DateGeneration::Forward)
523 .stub(StubConvention::LongBack)
524 .build()
525 .unwrap();
526 assert_eq!(s.dates(), &[d(2024, 1, 15), d(2024, 7, 15), d(2025, 4, 10)]);
528 }
529
530 #[test]
531 fn zero_rule_is_endpoints_only() {
532 let s = unadjusted_builder(d(2024, 1, 15), d(2034, 1, 15), Period::months(6))
533 .rule(DateGeneration::Zero)
534 .build()
535 .unwrap();
536 assert_eq!(s.dates(), &[d(2024, 1, 15), d(2034, 1, 15)]);
537 }
538
539 #[test]
540 fn end_of_month_rolling() {
541 let s = unadjusted_builder(d(2024, 1, 31), d(2024, 7, 31), Period::months(1))
543 .end_of_month(true)
544 .build()
545 .unwrap();
546 assert_eq!(
547 s.dates(),
548 &[
549 d(2024, 1, 31),
550 d(2024, 2, 29), d(2024, 3, 31),
552 d(2024, 4, 30),
553 d(2024, 5, 31),
554 d(2024, 6, 30),
555 d(2024, 7, 31),
556 ]
557 );
558 }
559
560 #[test]
561 fn third_wednesday_rule_snaps_dates() {
562 let s = unadjusted_builder(d(2025, 3, 19), d(2025, 12, 17), Period::months(3))
564 .rule(DateGeneration::ThirdWednesday)
565 .build()
566 .unwrap();
567 assert_eq!(
568 s.dates(),
569 &[
570 d(2025, 3, 19),
571 d(2025, 6, 18),
572 d(2025, 9, 17),
573 d(2025, 12, 17),
574 ]
575 );
576 }
577
578 #[test]
579 fn adjustment_moves_dates_to_business_days() {
580 let s = Schedule::builder(d(2024, 1, 13), d(2024, 7, 13), Period::months(3))
584 .calendar(Box::new(Brazil))
585 .convention(BusinessDayConvention::Following)
586 .build()
587 .unwrap();
588 for &date in s.dates() {
589 assert!(Brazil.is_business_day(date), "{date} should be adjusted");
590 }
591 }
592
593 #[test]
594 fn rejects_empty_range() {
595 let err = unadjusted_builder(d(2025, 1, 15), d(2025, 1, 15), Period::months(6))
596 .build()
597 .unwrap_err();
598 assert!(matches!(err, ScheduleError::EmptyRange { .. }));
599 }
600
601 #[test]
602 fn rejects_non_positive_tenor() {
603 let err = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(0))
604 .build()
605 .unwrap_err();
606 assert!(matches!(err, ScheduleError::NonPositiveTenor(0)));
607 }
608
609 #[test]
610 fn rejects_stub_direction_mismatch() {
611 let err = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(6))
612 .rule(DateGeneration::Backward)
613 .stub(StubConvention::ShortBack)
614 .build()
615 .unwrap_err();
616 assert!(matches!(err, ScheduleError::StubDirectionMismatch { .. }));
617 }
618
619 #[test]
620 fn iteration_and_indexing() {
621 let s = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(6))
622 .build()
623 .unwrap();
624 assert_eq!(s[0], d(2024, 1, 15));
625 let collected: Vec<Date> = s.iter().copied().collect();
626 assert_eq!(collected, s.dates().to_vec());
627 assert_eq!((&s).into_iter().count(), 3);
628 }
629}