diff --git a/README.md b/README.md index 89eaee9..96518e3 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Quantrs is a tiny quantitative finance library for Rust. It is designed to be as intuitive and easy to use as possible so that you can work with derivatives without the need to write complex code or have a PhD in reading QuantLib documentation. -The library is still in the early stages of development, and many features are not yet implemented. +The library is still in the early stages of development and many features are not yet implemented. Please check out the documentation [here][docs-url]. @@ -86,6 +86,18 @@ Quantrs supports options pricing with various models for both vanilla and exotic +### Fixed Income + +- Bond Types + - [x] _Zero-Coupon Bonds_ + - [ ] _Treasury Bonds_ (fixed-rate coupon) + - [ ] _Corporate Bonds_ (fixed-rate coupon with credit spreads) + - [ ] _Floating-Rate Bonds_ (variable coupon with caps/floors) +- [ ] Duration (_Macaulay_, _Modified_, _Effective_) +- [ ] Convexity +- [ ] Yield Measures (_YTM_, _YTC_, _YTW_) +- [x] Day Count Conventions (_ACT/365F_, _ACT/365_, _ACT/360_, _30/360 US_, _30/360 Eurobond_, _ACT/ACT ISDA_, _ACT/ACT ICMA_) + ## Usage Add this to your `Cargo.toml`: diff --git a/examples/fixed_income.rs b/examples/fixed_income.rs new file mode 100644 index 0000000..efddfc1 --- /dev/null +++ b/examples/fixed_income.rs @@ -0,0 +1,22 @@ +use quantrs::fixed_income::{Bond, DayCount, ZeroCouponBond}; + +fn main() { + let face_value = 1000.0; + let maturity = chrono::NaiveDate::from_ymd_opt(2030, 1, 1).unwrap_or_default(); + let settlement = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).unwrap_or_default(); + let ytm = 0.05; // 5% yield to maturity + let day_count = DayCount::ActActICMA; + + let zero_coupon_bond = ZeroCouponBond::new(face_value, maturity); + + match zero_coupon_bond.price(settlement, ytm, day_count) { + Ok(price_result) => { + println!("Clean Price: {:.2}", price_result.clean); + println!("Dirty Price: {:.2}", price_result.dirty); + println!("Accrued Interest: {:.2}", price_result.accrued); + } + Err(e) => { + eprintln!("Error pricing bond: {}", e); + } + } +} diff --git a/src/fixed_income.rs b/src/fixed_income.rs index b9399b4..b536f95 100644 --- a/src/fixed_income.rs +++ b/src/fixed_income.rs @@ -18,6 +18,7 @@ //! - [Zero-Coupon Bonds](bonds/struct.ZeroCouponBond.html) pub use self::bond_pricing::*; +pub use self::bonds::*; pub use self::cashflow::*; pub use self::types::*; pub use traits::*; diff --git a/src/fixed_income/bonds.rs b/src/fixed_income/bonds.rs index eb2e131..f2a3398 100644 --- a/src/fixed_income/bonds.rs +++ b/src/fixed_income/bonds.rs @@ -1,11 +1,11 @@ -//! Module for various bond . +//! Module for various bond types. // pub use corporate::CorporateBond; // pub use floating_rate::FloatingRateBond; // pub use treasury::TreasuryBond; -// pub use zero_coupon::ZeroCouponBond; +pub use zero_coupon::ZeroCouponBond; // mod corporate; // mod floating_rate; // mod treasury; -// mod zero_coupon; +mod zero_coupon; diff --git a/src/fixed_income/bonds/zero_coupon.rs b/src/fixed_income/bonds/zero_coupon.rs new file mode 100644 index 0000000..240b5f6 --- /dev/null +++ b/src/fixed_income/bonds/zero_coupon.rs @@ -0,0 +1,83 @@ +/// Zero Coupon Bond implementation +/// +/// Example: +/// +/// use quantrs::fixed_income::{Bond, DayCount, ZeroCouponBond}; +/// fn main() { +/// let face_value = 1000.0; +/// let maturity = chrono::NaiveDate::from_ymd_opt(2030, 1, 1).unwrap_or_default(); +/// let settlement = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).unwrap_or_default(); +/// let ytm = 0.05; // 5% yield to maturity +/// let day_count = DayCount::ActActICMA; +/// let zero_coupon_bond = ZeroCouponBond::new(face_value, maturity); +/// match zero_coupon_bond.price(settlement, ytm, day_count) { +/// Ok(price_result) => { +/// println!("Clean Price: {:.2}", price_result.clean); +/// println!("Dirty Price: {:.2}", price_result.dirty); +/// println!("Accrued Interest: {:.2}", price_result.accrued); +/// } +/// Err(e) => { +/// eprintln!("Error pricing bond: {}", e); +/// } +/// } +/// } +/// +/// Note: Zero coupon bonds do not have accrued interest. +/// +/// # References +/// - Fabozzi, Frank J. "Bond Markets, Analysis and Strategies." 9th Edition. Pearson, 2013. +/// - https://dqydj.com/zero-coupon-bond-calculator +use crate::fixed_income::{Bond, BondPricingError, DayCount, PriceResult}; +use chrono::NaiveDate; + +#[derive(Debug, Clone)] +pub struct ZeroCouponBond { + pub face_value: f64, + pub maturity: NaiveDate, +} + +impl ZeroCouponBond { + pub fn new(face_value: f64, maturity: NaiveDate) -> Self { + Self { + face_value, + maturity, + } + } +} + +impl Bond for ZeroCouponBond { + fn price( + &self, + settlement: NaiveDate, + ytm: f64, + day_count: DayCount, + ) -> Result { + if ytm < 0.0 { + return Err(BondPricingError::invalid_yield(ytm)); + } + + if settlement >= self.maturity { + return Err(BondPricingError::settlement_after_maturity( + settlement, + self.maturity, + )); + } + + let years_to_maturity = crate::fixed_income::DayCountConvention::year_fraction( + &day_count, + settlement, + self.maturity, + ); + + let clean_price = self.face_value / (1.0 + ytm).powf(years_to_maturity); + let accrued = self.accrued_interest(settlement, day_count); + let dirty_price = clean_price; + + Ok(PriceResult::new(clean_price, dirty_price, accrued)) + } + + fn accrued_interest(&self, _settlement: NaiveDate, _day_count: DayCount) -> f64 { + // Zero coupon bonds have no accrued interest + 0.0 + } +} diff --git a/src/fixed_income/day_count.rs b/src/fixed_income/day_count.rs index 7d682a5..9f35cac 100644 --- a/src/fixed_income/day_count.rs +++ b/src/fixed_income/day_count.rs @@ -1,3 +1,11 @@ +/// Implementations of various day count conventions for fixed income calculations. +/// +/// References: +/// - https://www.isda.org/2011/01/07/act-act-icma +/// - https://www.isda.org/a/NIJEE/ICMA-Rule-Book-Rule-251-reproduced-by-permission-of-ICMA.pdf +/// - https://quant.stackexchange.com/questions/71858 +/// - https://www.investopedia.com/terms/d/daycountconvention.asp +/// - https://en.wikipedia.org/wiki/Day_count_convention use crate::fixed_income::{DayCount, DayCountConvention}; use chrono::{Datelike, NaiveDate}; @@ -8,17 +16,15 @@ impl DayCountConvention for DayCount { match self { DayCount::Act365F => days / 365.0, DayCount::Act360 => days / 360.0, - DayCount::Act365 => days / 365.0, + DayCount::Act365 => { + let is_leap = chrono::NaiveDate::from_ymd_opt(start.year(), 2, 29).is_some(); + let year_days = if is_leap { 366.0 } else { 365.0 }; + days / year_days + } DayCount::Thirty360US => days / 360.0, DayCount::Thirty360E => days / 360.0, - DayCount::ActActISDA => { - // More complex calculation for actual/actual ISDA - self.act_act_isda_year_fraction(start, end) - } - DayCount::ActActICMA => { - // ICMA method - requires coupon frequency - days / 365.0 // TODO: Simplified - } + DayCount::ActActISDA => self.act_act_isda_year_fraction(start, end), + DayCount::ActActICMA => self.act_act_icma_year_fraction(start, end), } } @@ -84,4 +90,9 @@ impl DayCount { days / year_days } + + fn act_act_icma_year_fraction(&self, start: NaiveDate, end: NaiveDate) -> f64 { + // TODO: Implement proper ACT/ACT ICMA calculation based on coupon periods + 0.0 + } } diff --git a/src/fixed_income/types.rs b/src/fixed_income/types.rs index f298eee..b1900b3 100644 --- a/src/fixed_income/types.rs +++ b/src/fixed_income/types.rs @@ -7,16 +7,16 @@ pub enum DayCount { /// Actual/365 Fixed - 365 days per year Act365F, - /// 30/360 US (Bond Basis) - 30 days per month, 360 days per year - Thirty360US, - /// Actual/Actual ISDA - actual days, actual year length - ActActISDA, + /// Actual/365 - actual days, 365 days per year (no leap year adjustment) + Act365, /// Actual/360 - actual days, 360 days per year Act360, + /// 30/360 US (Bond Basis) - 30 days per month, 360 days per year + Thirty360US, /// 30/360 European - European version of 30/360 Thirty360E, - /// Actual/365 - actual days, 365 days per year (no leap year adjustment) - Act365, + /// Actual/Actual ISDA - actual days, actual year length + ActActISDA, /// Actual/Actual ICMA - used for bonds ActActICMA, } diff --git a/tests/fixed_income.rs b/tests/fixed_income.rs index 031975b..d2c1f84 100644 --- a/tests/fixed_income.rs +++ b/tests/fixed_income.rs @@ -1,10 +1,50 @@ use chrono::NaiveDate; -use quantrs::fixed_income::{BondPricingError, DayCount, PriceResult}; +use quantrs::fixed_income::DayCount; #[cfg(test)] mod tests { use super::*; + mod zero_coupon_bond_tests { + use approx::assert_abs_diff_eq; + use chrono::NaiveDate; + use quantrs::fixed_income::{Bond, DayCount, ZeroCouponBond}; + + #[test] + fn test_zero_coupon_bond_creation() { + let maturity = NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(); + let bond = ZeroCouponBond::new(1000.0, maturity); + + assert_eq!(bond.face_value, 1000.0); + assert_eq!(bond.maturity, maturity); + } + + #[test] + fn test_zero_coupon_bond_pricing() { + let settlement = NaiveDate::from_ymd_opt(2025, 6, 19).unwrap(); + let maturity = NaiveDate::from_ymd_opt(2035, 9, 19).unwrap(); + let bond = ZeroCouponBond::new(1000.0, maturity); + + let result = bond.price(settlement, 0.04, DayCount::Act365F); + assert!(result.is_ok()); + + let price_result = result.unwrap(); + assert_abs_diff_eq!(price_result.clean, 668.77, epsilon = 0.1); + assert_eq!(price_result.accrued, 0.0); // Zero coupon bonds have no accrued interest + assert_eq!(price_result.dirty, price_result.clean); + } + + #[test] + fn test_zero_coupon_accrued_interest() { + let settlement = NaiveDate::from_ymd_opt(2025, 8, 19).unwrap(); + let maturity = NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(); + let bond = ZeroCouponBond::new(1000.0, maturity); + + let accrued = bond.accrued_interest(settlement, DayCount::Act365F); + assert_eq!(accrued, 0.0); // Zero coupon bonds have no accrued interest + } + } + mod cashflow_tests { use quantrs::fixed_income::generate_schedule; @@ -29,6 +69,29 @@ mod tests { assert_eq!(schedule[0], date); } + #[test] + fn test_day_count_enum() { + let day_count = DayCount::Act365F; + assert_eq!(day_count, DayCount::Act365F); + + let day_count = DayCount::Thirty360US; + assert_eq!(day_count, DayCount::Thirty360US); + } + } + + mod bond_pricing_tests { + use chrono::NaiveDate; + use quantrs::fixed_income::{BondPricingError, PriceResult}; + + #[test] + fn test_invalid_frequency_error() { + let error = BondPricingError::InvalidFrequency(3); + assert_eq!( + format!("{}", error), + "Invalid coupon frequency: 3. Must be 1, 2, 4, or 12" + ); + } + #[test] fn test_price_result_creation() { let result = PriceResult { @@ -36,55 +99,280 @@ mod tests { dirty: 100.2, accrued: 1.7, }; + assert_eq!(result.clean, 98.5); + assert_eq!(result.dirty, 100.2); + assert_eq!(result.accrued, 1.7); } #[test] fn test_bond_pricing_error_display() { let error = BondPricingError::InvalidYield(1.5); assert_eq!(format!("{}", error), "Invalid yield to maturity: 1.5"); + + let settlement = NaiveDate::from_ymd_opt(2025, 8, 19).unwrap(); + let maturity = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(); + let error = BondPricingError::settlement_after_maturity(settlement, maturity); + assert!(format!("{}", error).contains("Settlement date")); } } - #[test] - fn test_bond_pricing_error_display() { - let error = BondPricingError::InvalidYield(1.5); - assert_eq!(format!("{}", error), "Invalid yield to maturity: 1.5"); + mod day_count_tests { + use approx::assert_abs_diff_eq; + use chrono::NaiveDate; + use quantrs::fixed_income::{DayCount, DayCountConvention}; - let settlement = NaiveDate::from_ymd_opt(2025, 8, 19).unwrap(); - let maturity = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(); - let error = BondPricingError::settlement_after_maturity(settlement, maturity); - assert!(format!("{}", error).contains("Settlement date")); - } + #[test] + fn test_act365f_day_count() { + let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let end = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); // 365 days + let day_count = DayCount::Act365F; - #[test] - fn test_invalid_frequency_error() { - let error = BondPricingError::InvalidFrequency(3); - assert_eq!( - format!("{}", error), - "Invalid coupon frequency: 3. Must be 1, 2, 4, or 12" - ); - } + let days = day_count.day_count(start, end); + let year_fraction = day_count.year_fraction(start, end); - #[test] - fn test_day_count_enum() { - let day_count = DayCount::Act365F; - assert_eq!(day_count, DayCount::Act365F); + assert_eq!(days, 365); + assert_abs_diff_eq!(year_fraction, 1.0, epsilon = 0.0001); // Should be exactly 1 year + } - let day_count = DayCount::Thirty360US; - assert_eq!(day_count, DayCount::Thirty360US); - } + #[test] + fn test_act360_day_count() { + let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 4, 1).unwrap(); // 90 days + let day_count = DayCount::Act360; + + let days = day_count.day_count(start, end); + let year_fraction = day_count.year_fraction(start, end); + + assert_eq!(days, 90); + assert_abs_diff_eq!(year_fraction, 0.25, epsilon = 0.0001); // 90/360 = 0.25 + } + + #[test] + fn test_thirty360us_same_month() { + let start = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 1, 25).unwrap(); + let day_count = DayCount::Thirty360US; + + let days = day_count.day_count(start, end); + let year_fraction = day_count.year_fraction(start, end); + + assert_eq!(days, 10); // 25 - 15 = 10 days + assert_abs_diff_eq!(year_fraction, 10.0 / 360.0, epsilon = 0.0001); + } + + #[test] + fn test_thirty360us_different_months() { + let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 7, 1).unwrap(); // 6 months + let day_count = DayCount::Thirty360US; + + let days = day_count.day_count(start, end); + let year_fraction = day_count.year_fraction(start, end); + + assert_eq!(days, 180); // 6 months * 30 days = 180 days + assert_abs_diff_eq!(year_fraction, 0.5, epsilon = 0.0001); // 180/360 = 0.5 + } + + #[test] + fn test_thirty360us_end_of_month() { + // Test 30/360 US rule: if day 1 is 31st, change to 30th + let start = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 2, 28).unwrap(); + let day_count = DayCount::Thirty360US; + + let days = day_count.day_count(start, end); + + // Should treat Jan 31 as Jan 30, so Feb 28 - Jan 30 = 28 days in 30/360 + assert_eq!(days, 28); + } + + #[test] + fn test_thirty360us_both_end_of_month() { + // Test rule: if both dates are 31st and day1 >= 30, change day2 to 30 + let start = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(); + let day_count = DayCount::Thirty360US; + + let days = day_count.day_count(start, end); - #[test] - fn test_price_result_creation() { - let result = PriceResult { - clean: 98.5, - dirty: 100.2, - accrued: 1.7, - }; - - assert_eq!(result.clean, 98.5); - assert_eq!(result.dirty, 100.2); - assert_eq!(result.accrued, 1.7); + // Jan 31 -> Jan 30, Mar 31 -> Mar 30, so 2 months = 60 days + assert_eq!(days, 60); + } + + #[test] + fn test_thirty360e_end_of_month() { + // Test European rule: any 31st becomes 30th + let start = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(); + let day_count = DayCount::Thirty360E; + + let days = day_count.day_count(start, end); + + // Both 31st become 30th, so exactly 2 months = 60 days + assert_eq!(days, 60); + } + + #[test] + fn test_actact_isda_leap_year() { + let start = NaiveDate::from_ymd_opt(2023, 12, 31).unwrap(); + let end = NaiveDate::from_ymd_opt(2024, 3, 3).unwrap(); + assert_eq!(DayCount::ActActISDA.day_count(start, end), 63); + assert_eq!(DayCount::ActActISDA.year_fraction(start, end), 63.0 / 365.0); + + let start = NaiveDate::from_ymd_opt(2024, 2, 28).unwrap(); + let end = NaiveDate::from_ymd_opt(2024, 2, 29).unwrap(); + assert_eq!(DayCount::ActActISDA.day_count(start, end), 1); + assert_eq!(DayCount::ActActISDA.year_fraction(start, end), 1.0 / 366.0); + } + + #[test] + fn test_actact_isda_non_leap_year() { + let start = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(); + let end: NaiveDate = NaiveDate::from_ymd_opt(2026, 3, 3).unwrap(); + assert_eq!(DayCount::ActActISDA.day_count(start, end), 62); + assert_eq!(DayCount::ActActISDA.year_fraction(start, end), 62.0 / 365.0); + } + + // TODOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO + + #[test] + fn test_actact_icma_leap_year() { + let start = NaiveDate::from_ymd_opt(2023, 12, 31).unwrap(); + let end = NaiveDate::from_ymd_opt(2024, 3, 3).unwrap(); + assert_eq!(DayCount::ActActICMA.day_count(start, end), 63); + assert_eq!(DayCount::ActActICMA.year_fraction(start, end), 63.0 / 365.0); + + let start = NaiveDate::from_ymd_opt(2024, 2, 28).unwrap(); + let end = NaiveDate::from_ymd_opt(2024, 2, 29).unwrap(); + assert_eq!(DayCount::ActActICMA.day_count(start, end), 1); + assert_eq!(DayCount::ActActICMA.year_fraction(start, end), 1.0 / 365.0); + } + + #[test] + fn test_actact_icma_non_leap_year() { + let start = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(); + let end = NaiveDate::from_ymd_opt(2026, 3, 3).unwrap(); + assert_eq!(DayCount::ActActICMA.day_count(start, end), 62); + assert_eq!(DayCount::ActActICMA.year_fraction(start, end), 62.0 / 365.0); + } + + #[test] + fn test_different_day_counts_same_period() { + let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 7, 1).unwrap(); // 6 months + + let act365f = DayCount::Act365F.year_fraction(start, end); + let act360 = DayCount::Act360.year_fraction(start, end); + let thirty360us = DayCount::Thirty360US.year_fraction(start, end); + + // All should be different values for the same period + assert!(act365f != act360); + assert!(act360 != thirty360us); + assert!(act365f != thirty360us); + + // 30/360 should be exactly 0.5 for 6 months + assert_abs_diff_eq!(thirty360us, 0.5, epsilon = 0.0001); + } + + #[test] + fn test_leap_year_handling() { + // 2024 is a leap year + let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); // 366 days (leap year) + + let act365f = DayCount::Act365F.year_fraction(start, end); + let act_act_isda = DayCount::ActActISDA.year_fraction(start, end); + + // Act/365F always uses 365 + assert_abs_diff_eq!(act365f, 366.0 / 365.0, epsilon = 0.0001); + + // Act/Act ISDA should handle leap year properly (closer to 1.0) + assert!(act_act_isda > act365f); // Should be more accurate for leap year + } + + #[test] + fn test_zero_day_period() { + let date = NaiveDate::from_ymd_opt(2025, 6, 15).unwrap(); + let day_count = DayCount::Act365F; + + let days = day_count.day_count(date, date); + let year_fraction = day_count.year_fraction(date, date); + + assert_eq!(days, 0); + assert_eq!(year_fraction, 0.0); + } + + #[test] + fn test_short_period() { + let start = NaiveDate::from_ymd_opt(2025, 6, 15).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 6, 16).unwrap(); // 1 day + let day_count = DayCount::Act365F; + + let days = day_count.day_count(start, end); + let year_fraction = day_count.year_fraction(start, end); + + assert_eq!(days, 1); + assert_abs_diff_eq!(year_fraction, 1.0 / 365.0, epsilon = 0.000001); + } + + #[test] + fn test_all_day_count_conventions() { + let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(); + + let conventions = [ + DayCount::Act365F, + DayCount::Act365, + DayCount::Act360, + DayCount::Thirty360US, + DayCount::Thirty360E, + DayCount::ActActISDA, + DayCount::ActActICMA, + ]; + + for convention in &conventions { + let days = convention.day_count(start, end); + let year_fraction = convention.year_fraction(start, end); + + // All should produce valid results + assert!( + days > 0, + "Day count should be positive for {:?}", + convention + ); + assert!( + year_fraction > 0.0, + "Year fraction should be positive for {:?}", + convention + ); + assert!( + year_fraction < 2.0, + "Year fraction should be reasonable for {:?}", + convention + ); + } + } + + #[test] + fn test_day_count_consistency() { + let start = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 9, 15).unwrap(); // 6 months + + // Test that the relationship between day_count and year_fraction is consistent + for convention in [DayCount::Act365F, DayCount::Act360, DayCount::Thirty360US] { + let days = convention.day_count(start, end) as f64; + let year_fraction = convention.year_fraction(start, end); + + let expected_year_fraction = match convention { + DayCount::Act365F => days / 365.0, + DayCount::Act360 => days / 360.0, + DayCount::Thirty360US => days / 360.0, + _ => continue, + }; + + assert_abs_diff_eq!(year_fraction, expected_year_fraction, epsilon = 0.0001); + } + } } }