use std::fmt;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Date {
pub year: i32,
pub month: u32,
pub day: u32,
}
impl Date {
pub fn new(year: i32, month: u32, day: u32) -> Option<Self> {
if !(1..=12).contains(&month) || day < 1 {
return None;
}
let days = Self::days_in_month(year, month);
if day > days {
return None;
}
Some(Self { year, month, day })
}
pub fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
pub fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if Self::is_leap_year(year) {
29
} else {
28
}
}
_ => 0,
}
}
pub fn to_day_number(&self) -> i64 {
let mut y = self.year as i64;
let mut m = self.month as i64;
if m <= 2 {
y -= 1;
m += 12;
}
(365 * y) + (y / 4) - (y / 100) + (y / 400) + ((153 * (m + 1)) / 5) + self.day as i64 - 428
}
pub fn days_until(&self, other: &Date) -> i64 {
other.to_day_number() - self.to_day_number()
}
pub fn weekday(&self) -> Weekday {
match self.to_day_number().rem_euclid(7) {
0 => Weekday::Sunday,
1 => Weekday::Monday,
2 => Weekday::Tuesday,
3 => Weekday::Wednesday,
4 => Weekday::Thursday,
5 => Weekday::Friday,
_ => Weekday::Saturday,
}
}
pub fn is_month_end(&self) -> bool {
self.day == Self::days_in_month(self.year, self.month)
}
pub fn add_months(&self, months: i32) -> Date {
let total = self.year as i64 * 12 + (self.month as i64 - 1) + months as i64;
let year = total.div_euclid(12) as i32;
let month = total.rem_euclid(12) as u32 + 1;
let day = self.day.min(Self::days_in_month(year, month));
Date { year, month, day }
}
pub fn add_days(&self, days: i64) -> Date {
let target = self.to_day_number() + days;
let mut year = self.year + (days / 366) as i32 - 1;
loop {
let start = Date {
year,
month: 1,
day: 1,
}
.to_day_number();
let next = Date {
year: year + 1,
month: 1,
day: 1,
}
.to_day_number();
if target < start {
year -= 1;
continue;
}
if target >= next {
year += 1;
continue;
}
let mut remaining = target - start;
for month in 1..=12u32 {
let length = Self::days_in_month(year, month) as i64;
if remaining < length {
return Date {
year,
month,
day: remaining as u32 + 1,
};
}
remaining -= length;
}
unreachable!("a year holds all its days");
}
}
pub fn to_month_end(&self) -> Date {
Date {
year: self.year,
month: self.month,
day: Self::days_in_month(self.year, self.month),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum Weekday {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
}
impl Weekday {
pub fn is_weekend(self) -> bool {
matches!(self, Weekday::Saturday | Weekday::Sunday)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum DayCountConvention {
#[default]
Actual360,
Actual365Fixed,
Thirty360,
ActualActualISDA,
}
pub fn year_fraction(d1: Date, d2: Date, convention: DayCountConvention) -> f64 {
if d1 == d2 {
return 0.0;
}
let (start, end, sign) = if d1 <= d2 {
(d1, d2, 1.0)
} else {
(d2, d1, -1.0)
};
let fraction = match convention {
DayCountConvention::Actual360 => start.days_until(&end) as f64 / 360.0,
DayCountConvention::Actual365Fixed => start.days_until(&end) as f64 / 365.0,
DayCountConvention::Thirty360 => {
let mut d1_day = start.day;
let mut d2_day = end.day;
if d1_day == 31 {
d1_day = 30;
}
if d2_day == 31 && d1_day >= 30 {
d2_day = 30;
}
let days_360 = (end.year as i64 - start.year as i64) * 360
+ (end.month as i64 - start.month as i64) * 30
+ (d2_day as i64 - d1_day as i64);
days_360 as f64 / 360.0
}
DayCountConvention::ActualActualISDA => {
if start.year == end.year {
let year_days = if Date::is_leap_year(start.year) {
366.0
} else {
365.0
};
start.days_until(&end) as f64 / year_days
} else {
let end_of_first_year = Date::new(start.year, 12, 31).unwrap();
let start_of_last_year = Date::new(end.year, 1, 1).unwrap();
let first_year_days = if Date::is_leap_year(start.year) {
366.0
} else {
365.0
};
let last_year_days = if Date::is_leap_year(end.year) {
366.0
} else {
365.0
};
let days1 = start.days_until(&end_of_first_year) + 1;
let days2 = start_of_last_year.days_until(&end);
let middle_years = (end.year - start.year - 1).max(0) as f64;
(days1 as f64 / first_year_days) + middle_years + (days2 as f64 / last_year_days)
}
}
};
sign * fraction
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum Compounding {
#[default]
Continuous,
Annual,
Periodic(u32),
}
pub fn discount_factor(rate: f64, tau: f64, compounding: Compounding) -> f64 {
if !rate.is_finite() || !tau.is_finite() || tau < 0.0 {
return 0.0;
}
match compounding {
Compounding::Continuous => (-rate * tau).exp(),
Compounding::Annual => {
if rate <= -1.0 {
0.0
} else {
(1.0 + rate).powf(-tau)
}
}
Compounding::Periodic(m) => {
let m_f = m.max(1) as f64;
let base = 1.0 + rate / m_f;
if base <= 0.0 {
0.0
} else {
base.powf(-m_f * tau)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum BusinessDayConvention {
#[default]
Unadjusted,
Following,
ModifiedFollowing,
Preceding,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct BusinessCalendar {
holidays: Vec<Date>,
}
impl BusinessCalendar {
pub fn weekends_only() -> Self {
Self::default()
}
pub fn with_holidays(holidays: impl IntoIterator<Item = Date>) -> Self {
let mut holidays: Vec<Date> = holidays.into_iter().collect();
holidays.sort_unstable();
holidays.dedup();
Self { holidays }
}
pub fn is_business_day(&self, date: Date) -> bool {
!date.weekday().is_weekend() && self.holidays.binary_search(&date).is_err()
}
pub fn adjust(&self, date: Date, convention: BusinessDayConvention) -> Date {
match convention {
BusinessDayConvention::Unadjusted => date,
BusinessDayConvention::Following => self.roll(date, 1),
BusinessDayConvention::Preceding => self.roll(date, -1),
BusinessDayConvention::ModifiedFollowing => {
let forward = self.roll(date, 1);
if forward.month == date.month && forward.year == date.year {
forward
} else {
self.roll(date, -1)
}
}
}
}
fn roll(&self, date: Date, step: i32) -> Date {
let mut current = date;
for _ in 0..14 {
if self.is_business_day(current) {
return current;
}
current = current.add_days(step as i64);
}
current
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum ScheduleStub {
#[default]
ShortFirst,
LongFirst,
ShortLast,
LongLast,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CouponSchedule {
accrual: Vec<Date>,
payment: Vec<Date>,
}
impl CouponSchedule {
pub fn generate(
issue: Date,
maturity: Date,
frequency: u32,
stub: ScheduleStub,
convention: BusinessDayConvention,
calendar: &BusinessCalendar,
) -> Result<Self, FinanceError> {
let step = months_per_period(frequency)?;
if issue >= maturity {
return Err(FinanceError::InvalidInput("issue must precede maturity"));
}
let mut accrual = match stub {
ScheduleStub::ShortFirst | ScheduleStub::LongFirst => {
let mut dates = Vec::new();
let month_end = maturity.is_month_end();
let mut k = 0i32;
loop {
let date = anchored(maturity, -(k * step), month_end);
dates.push(date);
if date <= issue {
break;
}
k += 1;
}
dates.reverse();
if dates[0] < issue {
dates[0] = issue;
if stub == ScheduleStub::LongFirst && dates.len() > 2 {
dates.remove(1);
}
}
dates
}
ScheduleStub::ShortLast | ScheduleStub::LongLast => {
let mut dates = Vec::new();
let month_end = issue.is_month_end();
let mut k = 0i32;
loop {
let date = anchored(issue, k * step, month_end);
dates.push(date);
if date >= maturity {
break;
}
k += 1;
}
if *dates.last().expect("loop pushes at least once") > maturity {
let last = dates.len() - 1;
dates[last] = maturity;
if stub == ScheduleStub::LongLast && dates.len() > 2 {
dates.remove(last - 1);
}
}
dates
}
};
accrual.dedup();
Self::from_accrual_dates(accrual, convention, calendar)
}
pub fn regular(issue: Date, maturity: Date, frequency: u32) -> Result<Self, FinanceError> {
Self::generate(
issue,
maturity,
frequency,
ScheduleStub::ShortFirst,
BusinessDayConvention::Unadjusted,
&BusinessCalendar::weekends_only(),
)
}
pub fn covering(
settlement: Date,
maturity: Date,
frequency: u32,
) -> Result<Self, FinanceError> {
let step = months_per_period(frequency)?;
if settlement >= maturity {
return Err(FinanceError::InvalidInput(
"settlement must precede maturity",
));
}
let month_end = maturity.is_month_end();
let mut dates = Vec::new();
let mut k = 0i32;
loop {
let date = anchored(maturity, -(k * step), month_end);
dates.push(date);
if date <= settlement {
break;
}
k += 1;
}
dates.reverse();
Self::from_accrual_dates(
dates,
BusinessDayConvention::Unadjusted,
&BusinessCalendar::weekends_only(),
)
}
pub fn from_accrual_dates(
accrual: Vec<Date>,
convention: BusinessDayConvention,
calendar: &BusinessCalendar,
) -> Result<Self, FinanceError> {
if accrual.len() < 2 {
return Err(FinanceError::InvalidInput(
"a schedule needs at least two accrual dates",
));
}
if accrual.windows(2).any(|w| w[0] >= w[1]) {
return Err(FinanceError::InvalidInput(
"accrual dates must be strictly ascending",
));
}
let payment = accrual[1..]
.iter()
.map(|date| calendar.adjust(*date, convention))
.collect();
Ok(Self { accrual, payment })
}
pub fn accrual_dates(&self) -> &[Date] {
&self.accrual
}
pub fn payment_dates(&self) -> &[Date] {
&self.payment
}
pub fn period_count(&self) -> usize {
self.payment.len()
}
pub fn period(&self, index: usize) -> Option<(Date, Date)> {
Some((*self.accrual.get(index)?, *self.accrual.get(index + 1)?))
}
pub fn period_containing(&self, date: Date) -> Option<usize> {
(0..self.period_count()).find(|&i| self.accrual[i] <= date && date < self.accrual[i + 1])
}
}
fn months_per_period(frequency: u32) -> Result<i32, FinanceError> {
match frequency {
1 | 2 | 3 | 4 | 6 | 12 => Ok((12 / frequency) as i32),
_ => Err(FinanceError::InvalidInput(
"frequency must divide 12 evenly (1, 2, 3, 4, 6 or 12)",
)),
}
}
fn anchored(anchor: Date, months: i32, month_end: bool) -> Date {
let shifted = anchor.add_months(months);
if month_end {
shifted.to_month_end()
} else {
shifted
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Cashflow {
pub date: Date,
pub amount: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct BondPricingResult {
pub dirty_price: f64,
pub clean_price: f64,
pub accrued_interest: f64,
pub macaulay_duration: f64,
pub modified_duration: f64,
pub dv01: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FinanceError {
InvalidInput(&'static str),
SolverFailedToConverge,
}
impl fmt::Display for FinanceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput(msg) => write!(f, "invalid finance input: {msg}"),
Self::SolverFailedToConverge => {
write!(f, "yield to maturity solver failed to converge")
}
}
}
}
impl std::error::Error for FinanceError {}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct FixedRateBond {
face_value: f64,
coupon_rate: f64,
frequency: u32,
schedule: CouponSchedule,
day_count: DayCountConvention,
}
impl FixedRateBond {
pub fn new(
face_value: f64,
coupon_rate: f64,
frequency: u32,
schedule: CouponSchedule,
day_count: DayCountConvention,
) -> Result<Self, FinanceError> {
if !face_value.is_finite() || face_value <= 0.0 {
return Err(FinanceError::InvalidInput("face_value must be positive"));
}
if !coupon_rate.is_finite() || coupon_rate < 0.0 {
return Err(FinanceError::InvalidInput(
"coupon_rate must be non-negative",
));
}
months_per_period(frequency)?;
Ok(Self {
face_value,
coupon_rate,
frequency,
schedule,
day_count,
})
}
pub fn schedule(&self) -> &CouponSchedule {
&self.schedule
}
pub fn face_value(&self) -> f64 {
self.face_value
}
pub fn coupon_amount(&self, index: usize) -> Option<f64> {
let (start, end) = self.schedule.period(index)?;
Some(self.face_value * self.coupon_rate * year_fraction(start, end, self.day_count))
}
pub fn accrued_interest(&self, settlement: Date) -> f64 {
let Some(index) = self.schedule.period_containing(settlement) else {
return 0.0;
};
let (start, _) = self
.schedule
.period(index)
.expect("period_containing returned a valid index");
self.face_value * self.coupon_rate * year_fraction(start, settlement, self.day_count)
}
pub fn cashflows(&self, settlement: Date) -> Vec<Cashflow> {
let mut flows = Vec::new();
let last = self.schedule.period_count().saturating_sub(1);
for index in 0..self.schedule.period_count() {
let payment = self.schedule.payment_dates()[index];
if payment <= settlement {
continue;
}
let mut amount = self.coupon_amount(index).unwrap_or(0.0);
if index == last {
amount += self.face_value;
}
flows.push(Cashflow {
date: payment,
amount,
});
}
flows
}
pub fn price(&self, settlement: Date, ytm: f64) -> Result<BondPricingResult, FinanceError> {
if !ytm.is_finite() {
return Err(FinanceError::InvalidInput("ytm must be finite"));
}
let flows = self.cashflows(settlement);
if flows.is_empty() {
return Err(FinanceError::InvalidInput(
"no cashflows remain after settlement",
));
}
let compounding = Compounding::Periodic(self.frequency);
let mut dirty_price = 0.0f64;
let mut weighted_pv_sum = 0.0f64;
for flow in &flows {
let tau = year_fraction(settlement, flow.date, self.day_count);
let pv = flow.amount * discount_factor(ytm, tau, compounding);
dirty_price += pv;
weighted_pv_sum += tau * pv;
}
let macaulay_duration = if dirty_price > 0.0 {
weighted_pv_sum / dirty_price
} else {
0.0
};
let modified_duration = macaulay_duration / (1.0 + ytm / self.frequency as f64);
let accrued_interest = self.accrued_interest(settlement);
Ok(BondPricingResult {
dirty_price,
clean_price: dirty_price - accrued_interest,
accrued_interest,
macaulay_duration,
modified_duration,
dv01: dirty_price * modified_duration * 0.0001,
})
}
pub fn yield_to_maturity(
&self,
settlement: Date,
clean_price: f64,
) -> Result<f64, FinanceError> {
if !clean_price.is_finite() || clean_price <= 0.0 {
return Err(FinanceError::InvalidInput("clean_price must be positive"));
}
let mut ytm = self.coupon_rate.max(0.01);
for _ in 0..100 {
let priced = self.price(settlement, ytm)?;
let diff = priced.clean_price - clean_price;
if diff.abs() < 1e-8 {
return Ok(ytm);
}
let derivative = -priced.dirty_price * priced.modified_duration;
if derivative.abs() < 1e-12 {
return Err(FinanceError::SolverFailedToConverge);
}
ytm = (ytm - diff / derivative).max(-0.5);
}
Ok(ytm)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct BondSpec {
pub face_value: f64,
pub coupon_rate: f64,
pub frequency: u32,
pub issue: Date,
pub maturity: Date,
pub day_count: DayCountConvention,
pub stub: ScheduleStub,
pub business_day_convention: BusinessDayConvention,
}
impl BondSpec {
pub fn new(
face_value: f64,
coupon_rate: f64,
frequency: u32,
issue: Date,
maturity: Date,
day_count: DayCountConvention,
) -> Self {
Self {
face_value,
coupon_rate,
frequency,
issue,
maturity,
day_count,
stub: ScheduleStub::default(),
business_day_convention: BusinessDayConvention::default(),
}
}
pub fn with_stub(mut self, stub: ScheduleStub) -> Self {
self.stub = stub;
self
}
pub fn with_business_day_convention(mut self, convention: BusinessDayConvention) -> Self {
self.business_day_convention = convention;
self
}
pub fn schedule(&self, calendar: &BusinessCalendar) -> Result<CouponSchedule, FinanceError> {
CouponSchedule::generate(
self.issue,
self.maturity,
self.frequency,
self.stub,
self.business_day_convention,
calendar,
)
}
pub fn build(&self, calendar: &BusinessCalendar) -> Result<FixedRateBond, FinanceError> {
FixedRateBond::new(
self.face_value,
self.coupon_rate,
self.frequency,
self.schedule(calendar)?,
self.day_count,
)
}
}
pub fn price_bond(
face_value: f64,
coupon_rate: f64,
frequency: u32,
settlement: Date,
maturity: Date,
ytm: f64,
convention: DayCountConvention,
) -> Result<BondPricingResult, FinanceError> {
let schedule = CouponSchedule::covering(settlement, maturity, frequency)?;
FixedRateBond::new(face_value, coupon_rate, frequency, schedule, convention)?
.price(settlement, ytm)
}
pub fn yield_to_maturity(
clean_price: f64,
face_value: f64,
coupon_rate: f64,
frequency: u32,
settlement: Date,
maturity: Date,
convention: DayCountConvention,
) -> Result<f64, FinanceError> {
let schedule = CouponSchedule::covering(settlement, maturity, frequency)?;
FixedRateBond::new(face_value, coupon_rate, frequency, schedule, convention)?
.yield_to_maturity(settlement, clean_price)
}