1use std::fmt;
19
20#[cfg(feature = "serde")]
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
26pub struct Date {
27 pub year: i32,
28 pub month: u32,
29 pub day: u32,
30}
31
32impl Date {
33 pub fn new(year: i32, month: u32, day: u32) -> Option<Self> {
35 if !(1..=12).contains(&month) || day < 1 {
36 return None;
37 }
38 let days = Self::days_in_month(year, month);
39 if day > days {
40 return None;
41 }
42 Some(Self { year, month, day })
43 }
44
45 pub fn is_leap_year(year: i32) -> bool {
46 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
47 }
48
49 pub fn days_in_month(year: i32, month: u32) -> u32 {
50 match month {
51 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
52 4 | 6 | 9 | 11 => 30,
53 2 => {
54 if Self::is_leap_year(year) {
55 29
56 } else {
57 28
58 }
59 }
60 _ => 0,
61 }
62 }
63
64 pub fn to_day_number(&self) -> i64 {
66 let mut y = self.year as i64;
67 let mut m = self.month as i64;
68 if m <= 2 {
69 y -= 1;
70 m += 12;
71 }
72 (365 * y) + (y / 4) - (y / 100) + (y / 400) + ((153 * (m + 1)) / 5) + self.day as i64 - 428
74 }
75
76 pub fn days_until(&self, other: &Date) -> i64 {
78 other.to_day_number() - self.to_day_number()
79 }
80
81 pub fn weekday(&self) -> Weekday {
83 match self.to_day_number().rem_euclid(7) {
84 0 => Weekday::Sunday,
85 1 => Weekday::Monday,
86 2 => Weekday::Tuesday,
87 3 => Weekday::Wednesday,
88 4 => Weekday::Thursday,
89 5 => Weekday::Friday,
90 _ => Weekday::Saturday,
91 }
92 }
93
94 pub fn is_month_end(&self) -> bool {
96 self.day == Self::days_in_month(self.year, self.month)
97 }
98
99 pub fn add_months(&self, months: i32) -> Date {
105 let total = self.year as i64 * 12 + (self.month as i64 - 1) + months as i64;
106 let year = total.div_euclid(12) as i32;
107 let month = total.rem_euclid(12) as u32 + 1;
108 let day = self.day.min(Self::days_in_month(year, month));
109 Date { year, month, day }
110 }
111
112 pub fn add_days(&self, days: i64) -> Date {
114 let target = self.to_day_number() + days;
116 let mut year = self.year + (days / 366) as i32 - 1;
117 loop {
118 let start = Date {
119 year,
120 month: 1,
121 day: 1,
122 }
123 .to_day_number();
124 let next = Date {
125 year: year + 1,
126 month: 1,
127 day: 1,
128 }
129 .to_day_number();
130 if target < start {
131 year -= 1;
132 continue;
133 }
134 if target >= next {
135 year += 1;
136 continue;
137 }
138 let mut remaining = target - start;
139 for month in 1..=12u32 {
140 let length = Self::days_in_month(year, month) as i64;
141 if remaining < length {
142 return Date {
143 year,
144 month,
145 day: remaining as u32 + 1,
146 };
147 }
148 remaining -= length;
149 }
150 unreachable!("a year holds all its days");
151 }
152 }
153
154 pub fn to_month_end(&self) -> Date {
156 Date {
157 year: self.year,
158 month: self.month,
159 day: Self::days_in_month(self.year, self.month),
160 }
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
166#[cfg_attr(
167 feature = "serde",
168 derive(Serialize, Deserialize),
169 serde(rename_all = "snake_case")
170)]
171pub enum Weekday {
172 Sunday,
173 Monday,
174 Tuesday,
175 Wednesday,
176 Thursday,
177 Friday,
178 Saturday,
179}
180
181impl Weekday {
182 pub fn is_weekend(self) -> bool {
185 matches!(self, Weekday::Saturday | Weekday::Sunday)
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
191#[cfg_attr(
192 feature = "serde",
193 derive(Serialize, Deserialize),
194 serde(rename_all = "snake_case")
195)]
196pub enum DayCountConvention {
197 #[default]
199 Actual360,
200 Actual365Fixed,
202 Thirty360,
206 ActualActualISDA,
208}
209
210pub fn year_fraction(d1: Date, d2: Date, convention: DayCountConvention) -> f64 {
212 if d1 == d2 {
213 return 0.0;
214 }
215 let (start, end, sign) = if d1 <= d2 {
216 (d1, d2, 1.0)
217 } else {
218 (d2, d1, -1.0)
219 };
220
221 let fraction = match convention {
222 DayCountConvention::Actual360 => start.days_until(&end) as f64 / 360.0,
223 DayCountConvention::Actual365Fixed => start.days_until(&end) as f64 / 365.0,
224 DayCountConvention::Thirty360 => {
225 let mut d1_day = start.day;
226 let mut d2_day = end.day;
227 if d1_day == 31 {
228 d1_day = 30;
229 }
230 if d2_day == 31 && d1_day >= 30 {
231 d2_day = 30;
232 }
233 let days_360 = (end.year as i64 - start.year as i64) * 360
234 + (end.month as i64 - start.month as i64) * 30
235 + (d2_day as i64 - d1_day as i64);
236 days_360 as f64 / 360.0
237 }
238 DayCountConvention::ActualActualISDA => {
239 if start.year == end.year {
240 let year_days = if Date::is_leap_year(start.year) {
241 366.0
242 } else {
243 365.0
244 };
245 start.days_until(&end) as f64 / year_days
246 } else {
247 let end_of_first_year = Date::new(start.year, 12, 31).unwrap();
248 let start_of_last_year = Date::new(end.year, 1, 1).unwrap();
249
250 let first_year_days = if Date::is_leap_year(start.year) {
251 366.0
252 } else {
253 365.0
254 };
255 let last_year_days = if Date::is_leap_year(end.year) {
256 366.0
257 } else {
258 365.0
259 };
260
261 let days1 = start.days_until(&end_of_first_year) + 1;
262 let days2 = start_of_last_year.days_until(&end);
263
264 let middle_years = (end.year - start.year - 1).max(0) as f64;
265
266 (days1 as f64 / first_year_days) + middle_years + (days2 as f64 / last_year_days)
267 }
268 }
269 };
270
271 sign * fraction
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
276#[cfg_attr(
277 feature = "serde",
278 derive(Serialize, Deserialize),
279 serde(rename_all = "snake_case")
280)]
281pub enum Compounding {
282 #[default]
284 Continuous,
285 Annual,
287 Periodic(u32),
289}
290
291pub fn discount_factor(rate: f64, tau: f64, compounding: Compounding) -> f64 {
295 if !rate.is_finite() || !tau.is_finite() || tau < 0.0 {
296 return 0.0;
297 }
298 match compounding {
299 Compounding::Continuous => (-rate * tau).exp(),
300 Compounding::Annual => {
301 if rate <= -1.0 {
302 0.0
303 } else {
304 (1.0 + rate).powf(-tau)
305 }
306 }
307 Compounding::Periodic(m) => {
308 let m_f = m.max(1) as f64;
309 let base = 1.0 + rate / m_f;
310 if base <= 0.0 {
311 0.0
312 } else {
313 base.powf(-m_f * tau)
314 }
315 }
316 }
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
321#[cfg_attr(
322 feature = "serde",
323 derive(Serialize, Deserialize),
324 serde(rename_all = "snake_case")
325)]
326pub enum BusinessDayConvention {
327 #[default]
330 Unadjusted,
331 Following,
333 ModifiedFollowing,
335 Preceding,
337}
338
339#[derive(Debug, Clone, Default, PartialEq, Eq)]
345#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
346pub struct BusinessCalendar {
347 holidays: Vec<Date>,
348}
349
350impl BusinessCalendar {
351 pub fn weekends_only() -> Self {
353 Self::default()
354 }
355
356 pub fn with_holidays(holidays: impl IntoIterator<Item = Date>) -> Self {
358 let mut holidays: Vec<Date> = holidays.into_iter().collect();
359 holidays.sort_unstable();
360 holidays.dedup();
361 Self { holidays }
362 }
363
364 pub fn is_business_day(&self, date: Date) -> bool {
365 !date.weekday().is_weekend() && self.holidays.binary_search(&date).is_err()
366 }
367
368 pub fn adjust(&self, date: Date, convention: BusinessDayConvention) -> Date {
371 match convention {
372 BusinessDayConvention::Unadjusted => date,
373 BusinessDayConvention::Following => self.roll(date, 1),
374 BusinessDayConvention::Preceding => self.roll(date, -1),
375 BusinessDayConvention::ModifiedFollowing => {
376 let forward = self.roll(date, 1);
377 if forward.month == date.month && forward.year == date.year {
378 forward
379 } else {
380 self.roll(date, -1)
381 }
382 }
383 }
384 }
385
386 fn roll(&self, date: Date, step: i32) -> Date {
387 let mut current = date;
388 for _ in 0..14 {
391 if self.is_business_day(current) {
392 return current;
393 }
394 current = current.add_days(step as i64);
395 }
396 current
397 }
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
403#[cfg_attr(
404 feature = "serde",
405 derive(Serialize, Deserialize),
406 serde(rename_all = "snake_case")
407)]
408pub enum ScheduleStub {
409 #[default]
412 ShortFirst,
413 LongFirst,
416 ShortLast,
418 LongLast,
421}
422
423#[derive(Debug, Clone, PartialEq, Eq)]
441#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
442pub struct CouponSchedule {
443 accrual: Vec<Date>,
444 payment: Vec<Date>,
445}
446
447impl CouponSchedule {
448 pub fn generate(
453 issue: Date,
454 maturity: Date,
455 frequency: u32,
456 stub: ScheduleStub,
457 convention: BusinessDayConvention,
458 calendar: &BusinessCalendar,
459 ) -> Result<Self, FinanceError> {
460 let step = months_per_period(frequency)?;
461 if issue >= maturity {
462 return Err(FinanceError::InvalidInput("issue must precede maturity"));
463 }
464
465 let mut accrual = match stub {
466 ScheduleStub::ShortFirst | ScheduleStub::LongFirst => {
467 let mut dates = Vec::new();
468 let month_end = maturity.is_month_end();
469 let mut k = 0i32;
470 loop {
471 let date = anchored(maturity, -(k * step), month_end);
472 dates.push(date);
473 if date <= issue {
474 break;
475 }
476 k += 1;
477 }
478 dates.reverse();
479 if dates[0] < issue {
483 dates[0] = issue;
484 if stub == ScheduleStub::LongFirst && dates.len() > 2 {
485 dates.remove(1);
486 }
487 }
488 dates
489 }
490 ScheduleStub::ShortLast | ScheduleStub::LongLast => {
491 let mut dates = Vec::new();
492 let month_end = issue.is_month_end();
493 let mut k = 0i32;
494 loop {
495 let date = anchored(issue, k * step, month_end);
496 dates.push(date);
497 if date >= maturity {
498 break;
499 }
500 k += 1;
501 }
502 if *dates.last().expect("loop pushes at least once") > maturity {
503 let last = dates.len() - 1;
504 dates[last] = maturity;
505 if stub == ScheduleStub::LongLast && dates.len() > 2 {
506 dates.remove(last - 1);
507 }
508 }
509 dates
510 }
511 };
512 accrual.dedup();
513 Self::from_accrual_dates(accrual, convention, calendar)
514 }
515
516 pub fn regular(issue: Date, maturity: Date, frequency: u32) -> Result<Self, FinanceError> {
519 Self::generate(
520 issue,
521 maturity,
522 frequency,
523 ScheduleStub::ShortFirst,
524 BusinessDayConvention::Unadjusted,
525 &BusinessCalendar::weekends_only(),
526 )
527 }
528
529 pub fn covering(
536 settlement: Date,
537 maturity: Date,
538 frequency: u32,
539 ) -> Result<Self, FinanceError> {
540 let step = months_per_period(frequency)?;
541 if settlement >= maturity {
542 return Err(FinanceError::InvalidInput(
543 "settlement must precede maturity",
544 ));
545 }
546
547 let month_end = maturity.is_month_end();
548 let mut dates = Vec::new();
549 let mut k = 0i32;
550 loop {
551 let date = anchored(maturity, -(k * step), month_end);
552 dates.push(date);
553 if date <= settlement {
554 break;
555 }
556 k += 1;
557 }
558 dates.reverse();
559 Self::from_accrual_dates(
560 dates,
561 BusinessDayConvention::Unadjusted,
562 &BusinessCalendar::weekends_only(),
563 )
564 }
565
566 pub fn from_accrual_dates(
569 accrual: Vec<Date>,
570 convention: BusinessDayConvention,
571 calendar: &BusinessCalendar,
572 ) -> Result<Self, FinanceError> {
573 if accrual.len() < 2 {
574 return Err(FinanceError::InvalidInput(
575 "a schedule needs at least two accrual dates",
576 ));
577 }
578 if accrual.windows(2).any(|w| w[0] >= w[1]) {
579 return Err(FinanceError::InvalidInput(
580 "accrual dates must be strictly ascending",
581 ));
582 }
583
584 let payment = accrual[1..]
585 .iter()
586 .map(|date| calendar.adjust(*date, convention))
587 .collect();
588 Ok(Self { accrual, payment })
589 }
590
591 pub fn accrual_dates(&self) -> &[Date] {
593 &self.accrual
594 }
595
596 pub fn payment_dates(&self) -> &[Date] {
598 &self.payment
599 }
600
601 pub fn period_count(&self) -> usize {
602 self.payment.len()
603 }
604
605 pub fn period(&self, index: usize) -> Option<(Date, Date)> {
607 Some((*self.accrual.get(index)?, *self.accrual.get(index + 1)?))
608 }
609
610 pub fn period_containing(&self, date: Date) -> Option<usize> {
614 (0..self.period_count()).find(|&i| self.accrual[i] <= date && date < self.accrual[i + 1])
615 }
616}
617
618fn months_per_period(frequency: u32) -> Result<i32, FinanceError> {
620 match frequency {
621 1 | 2 | 3 | 4 | 6 | 12 => Ok((12 / frequency) as i32),
622 _ => Err(FinanceError::InvalidInput(
623 "frequency must divide 12 evenly (1, 2, 3, 4, 6 or 12)",
624 )),
625 }
626}
627
628fn anchored(anchor: Date, months: i32, month_end: bool) -> Date {
630 let shifted = anchor.add_months(months);
631 if month_end {
632 shifted.to_month_end()
633 } else {
634 shifted
635 }
636}
637
638#[derive(Debug, Clone, Copy, PartialEq)]
640#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
641pub struct Cashflow {
642 pub date: Date,
644 pub amount: f64,
645}
646
647#[derive(Debug, Clone, PartialEq)]
649#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
650pub struct BondPricingResult {
651 pub dirty_price: f64,
653 pub clean_price: f64,
655 pub accrued_interest: f64,
657 pub macaulay_duration: f64,
659 pub modified_duration: f64,
661 pub dv01: f64,
663}
664
665#[derive(Debug, Clone, PartialEq)]
667pub enum FinanceError {
668 InvalidInput(&'static str),
669 SolverFailedToConverge,
670}
671
672impl fmt::Display for FinanceError {
673 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
674 match self {
675 Self::InvalidInput(msg) => write!(f, "invalid finance input: {msg}"),
676 Self::SolverFailedToConverge => {
677 write!(f, "yield to maturity solver failed to converge")
678 }
679 }
680 }
681}
682
683impl std::error::Error for FinanceError {}
684
685#[derive(Debug, Clone, PartialEq)]
698#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
699pub struct FixedRateBond {
700 face_value: f64,
701 coupon_rate: f64,
702 frequency: u32,
703 schedule: CouponSchedule,
704 day_count: DayCountConvention,
705}
706
707impl FixedRateBond {
708 pub fn new(
709 face_value: f64,
710 coupon_rate: f64,
711 frequency: u32,
712 schedule: CouponSchedule,
713 day_count: DayCountConvention,
714 ) -> Result<Self, FinanceError> {
715 if !face_value.is_finite() || face_value <= 0.0 {
716 return Err(FinanceError::InvalidInput("face_value must be positive"));
717 }
718 if !coupon_rate.is_finite() || coupon_rate < 0.0 {
719 return Err(FinanceError::InvalidInput(
720 "coupon_rate must be non-negative",
721 ));
722 }
723 months_per_period(frequency)?;
724 Ok(Self {
725 face_value,
726 coupon_rate,
727 frequency,
728 schedule,
729 day_count,
730 })
731 }
732
733 pub fn schedule(&self) -> &CouponSchedule {
734 &self.schedule
735 }
736
737 pub fn face_value(&self) -> f64 {
738 self.face_value
739 }
740
741 pub fn coupon_amount(&self, index: usize) -> Option<f64> {
743 let (start, end) = self.schedule.period(index)?;
744 Some(self.face_value * self.coupon_rate * year_fraction(start, end, self.day_count))
745 }
746
747 pub fn accrued_interest(&self, settlement: Date) -> f64 {
753 let Some(index) = self.schedule.period_containing(settlement) else {
754 return 0.0;
755 };
756 let (start, _) = self
757 .schedule
758 .period(index)
759 .expect("period_containing returned a valid index");
760 self.face_value * self.coupon_rate * year_fraction(start, settlement, self.day_count)
761 }
762
763 pub fn cashflows(&self, settlement: Date) -> Vec<Cashflow> {
769 let mut flows = Vec::new();
770 let last = self.schedule.period_count().saturating_sub(1);
771 for index in 0..self.schedule.period_count() {
772 let payment = self.schedule.payment_dates()[index];
773 if payment <= settlement {
774 continue;
775 }
776 let mut amount = self.coupon_amount(index).unwrap_or(0.0);
777 if index == last {
778 amount += self.face_value;
779 }
780 flows.push(Cashflow {
781 date: payment,
782 amount,
783 });
784 }
785 flows
786 }
787
788 pub fn price(&self, settlement: Date, ytm: f64) -> Result<BondPricingResult, FinanceError> {
794 if !ytm.is_finite() {
795 return Err(FinanceError::InvalidInput("ytm must be finite"));
796 }
797 let flows = self.cashflows(settlement);
798 if flows.is_empty() {
799 return Err(FinanceError::InvalidInput(
800 "no cashflows remain after settlement",
801 ));
802 }
803
804 let compounding = Compounding::Periodic(self.frequency);
805 let mut dirty_price = 0.0f64;
806 let mut weighted_pv_sum = 0.0f64;
807 for flow in &flows {
808 let tau = year_fraction(settlement, flow.date, self.day_count);
809 let pv = flow.amount * discount_factor(ytm, tau, compounding);
810 dirty_price += pv;
811 weighted_pv_sum += tau * pv;
812 }
813
814 let macaulay_duration = if dirty_price > 0.0 {
815 weighted_pv_sum / dirty_price
816 } else {
817 0.0
818 };
819 let modified_duration = macaulay_duration / (1.0 + ytm / self.frequency as f64);
820 let accrued_interest = self.accrued_interest(settlement);
821
822 Ok(BondPricingResult {
823 dirty_price,
824 clean_price: dirty_price - accrued_interest,
825 accrued_interest,
826 macaulay_duration,
827 modified_duration,
828 dv01: dirty_price * modified_duration * 0.0001,
829 })
830 }
831
832 pub fn yield_to_maturity(
835 &self,
836 settlement: Date,
837 clean_price: f64,
838 ) -> Result<f64, FinanceError> {
839 if !clean_price.is_finite() || clean_price <= 0.0 {
840 return Err(FinanceError::InvalidInput("clean_price must be positive"));
841 }
842
843 let mut ytm = self.coupon_rate.max(0.01);
844 for _ in 0..100 {
845 let priced = self.price(settlement, ytm)?;
846 let diff = priced.clean_price - clean_price;
847 if diff.abs() < 1e-8 {
848 return Ok(ytm);
849 }
850 let derivative = -priced.dirty_price * priced.modified_duration;
852 if derivative.abs() < 1e-12 {
853 return Err(FinanceError::SolverFailedToConverge);
854 }
855 ytm = (ytm - diff / derivative).max(-0.5);
856 }
857 Ok(ytm)
858 }
859}
860
861#[derive(Debug, Clone, PartialEq)]
883#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
884pub struct BondSpec {
885 pub face_value: f64,
887 pub coupon_rate: f64,
889 pub frequency: u32,
891 pub issue: Date,
894 pub maturity: Date,
895 pub day_count: DayCountConvention,
897 pub stub: ScheduleStub,
899 pub business_day_convention: BusinessDayConvention,
901}
902
903impl BondSpec {
904 pub fn new(
908 face_value: f64,
909 coupon_rate: f64,
910 frequency: u32,
911 issue: Date,
912 maturity: Date,
913 day_count: DayCountConvention,
914 ) -> Self {
915 Self {
916 face_value,
917 coupon_rate,
918 frequency,
919 issue,
920 maturity,
921 day_count,
922 stub: ScheduleStub::default(),
923 business_day_convention: BusinessDayConvention::default(),
924 }
925 }
926
927 pub fn with_stub(mut self, stub: ScheduleStub) -> Self {
928 self.stub = stub;
929 self
930 }
931
932 pub fn with_business_day_convention(mut self, convention: BusinessDayConvention) -> Self {
933 self.business_day_convention = convention;
934 self
935 }
936
937 pub fn schedule(&self, calendar: &BusinessCalendar) -> Result<CouponSchedule, FinanceError> {
939 CouponSchedule::generate(
940 self.issue,
941 self.maturity,
942 self.frequency,
943 self.stub,
944 self.business_day_convention,
945 calendar,
946 )
947 }
948
949 pub fn build(&self, calendar: &BusinessCalendar) -> Result<FixedRateBond, FinanceError> {
953 FixedRateBond::new(
954 self.face_value,
955 self.coupon_rate,
956 self.frequency,
957 self.schedule(calendar)?,
958 self.day_count,
959 )
960 }
961}
962
963pub fn price_bond(
974 face_value: f64,
975 coupon_rate: f64,
976 frequency: u32,
977 settlement: Date,
978 maturity: Date,
979 ytm: f64,
980 convention: DayCountConvention,
981) -> Result<BondPricingResult, FinanceError> {
982 let schedule = CouponSchedule::covering(settlement, maturity, frequency)?;
983 FixedRateBond::new(face_value, coupon_rate, frequency, schedule, convention)?
984 .price(settlement, ytm)
985}
986
987pub fn yield_to_maturity(
991 clean_price: f64,
992 face_value: f64,
993 coupon_rate: f64,
994 frequency: u32,
995 settlement: Date,
996 maturity: Date,
997 convention: DayCountConvention,
998) -> Result<f64, FinanceError> {
999 let schedule = CouponSchedule::covering(settlement, maturity, frequency)?;
1000 FixedRateBond::new(face_value, coupon_rate, frequency, schedule, convention)?
1001 .yield_to_maturity(settlement, clean_price)
1002}