Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
48d4d72
Add FI module definition
carlobortolan Aug 19, 2025
ed94353
Add trait module for FI
carlobortolan Aug 19, 2025
fb1b1ac
Add PriceResult struct
carlobortolan Aug 19, 2025
5d0c5bb
Add FI types and custom pricing errors
carlobortolan Aug 19, 2025
aa8f815
Add ZeroCouponBond struct and implement Bond trait for pricing
carlobortolan Aug 19, 2025
9e72238
Update day_count to include year_fraction and day_count methods
carlobortolan Aug 19, 2025
2f8a9b2
Add TODOs
carlobortolan Aug 19, 2025
c01e75c
Merge branch '7-implement-bond-pricing' of github.com:carlobortolan/q…
carlobortolan Aug 19, 2025
3e5c4df
Refactor ZeroCouponBond pricing logic to use DayCountConvention
carlobortolan Aug 19, 2025
54977cc
Refactor bond module documentation and add test cases for ZeroCouponBond
carlobortolan Aug 19, 2025
726e543
Remove out unused future bond imports
carlobortolan Aug 19, 2025
bc40eb1
Add basic tests
carlobortolan Aug 19, 2025
bddbf3b
Add cashflow tests for schedule generation and price result validation
carlobortolan Aug 19, 2025
9c4c1e1
Merge branch '7-implement-bond-pricing' of github.com:carlobortolan/q…
carlobortolan Aug 19, 2025
5ae6580
Remove unused imports and bond_price function stub from bond_pricing.rs
carlobortolan Aug 19, 2025
5d525f1
Merge branch '7-implement-bond-pricing' of github.com:carlobortolan/q…
carlobortolan Aug 19, 2025
799f460
Update README and add Zero Coupon Bond example
carlobortolan Aug 30, 2025
cb5b2be
Merge branch 'master' of github.com:carlobortolan/quantrs into 73-imp…
carlobortolan Aug 30, 2025
32aed25
update zero coupon bond tests and add day count tests
carlobortolan Aug 30, 2025
3596b05
add maturity handling to FI day_counts
carlobortolan Aug 31, 2025
87a1a94
fix linting
carlobortolan Aug 31, 2025
7d2df55
add validation tests for ZeroCouponBond pricing errors
carlobortolan Aug 31, 2025
4c544c1
update tests
carlobortolan Aug 31, 2025
42839be
remove unused leap year check
carlobortolan Aug 31, 2025
a318515
update icma daycount tests
carlobortolan Aug 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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].

Expand Down Expand Up @@ -86,6 +86,18 @@ Quantrs supports options pricing with various models for both vanilla and exotic

</details>

### 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`:
Expand Down
22 changes: 22 additions & 0 deletions examples/fixed_income.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
1 change: 1 addition & 0 deletions src/fixed_income.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
6 changes: 3 additions & 3 deletions src/fixed_income/bonds.rs
Original file line number Diff line number Diff line change
@@ -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;
83 changes: 83 additions & 0 deletions src/fixed_income/bonds/zero_coupon.rs
Original file line number Diff line number Diff line change
@@ -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<PriceResult, BondPricingError> {
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
}
}
185 changes: 164 additions & 21 deletions src/fixed_income/day_count.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,34 @@
use crate::fixed_income::{DayCount, DayCountConvention};
/// 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
/// - https://support.treasurysystems.com/support/solutions/articles/103000058036-day-count-conventions
use crate::{
fixed_income::{DayCount, DayCountConvention},
log_warn,
};
use chrono::{Datelike, NaiveDate};

impl DayCountConvention for DayCount {
fn year_fraction(&self, start: NaiveDate, end: NaiveDate) -> f64 {
let days = self.day_count(start, end) as f64;

match self {
DayCount::Act365F => days / 365.0,
DayCount::Act360 => days / 360.0,
DayCount::Act365 => days / 365.0,
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::Act365F => self.day_count(start, end) as f64 / 365.0,
DayCount::Act360 => self.day_count(start, end) as f64 / 360.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 };
self.day_count(start, end) as f64 / year_days
}
DayCount::Thirty360US => self.day_count(start, end) as f64 / 360.0,
DayCount::Thirty360E => self.day_count(start, end) as f64 / 360.0,
DayCount::ActActISDA => self.act_act_isda_year_fraction(start, end),
DayCount::ActActICMA => {
// ICMA method - requires coupon frequency
days / 365.0 // TODO: Simplified
log_warn!("Act/Act ICMA year fraction called without maturity and frequency; defaulting to semi-annual frequency and end date as maturity. Use year_fraction_with_maturity for accurate results.");
self.act_act_icma_year_fraction(start, end, 2, end)
}
}
}
Expand All @@ -33,6 +44,23 @@ impl DayCountConvention for DayCount {
DayCount::ActActICMA => (end - start).num_days() as i32,
}
}

fn year_fraction_with_maturity(
&self,
start: NaiveDate,
end: NaiveDate,
frequency: i32,
maturity: NaiveDate,
) -> f64 {
match self {
DayCount::ActActICMA => {
// For simplified implementation, assume semi-annual frequency
// In real usage, this would come from bond parameters
self.act_act_icma_year_fraction(start, end, frequency, maturity)
}
_ => self.year_fraction(start, end),
}
}
}

impl DayCount {
Expand Down Expand Up @@ -75,13 +103,128 @@ impl DayCount {
}

fn act_act_isda_year_fraction(&self, start: NaiveDate, end: NaiveDate) -> f64 {
// Simplified ACT/ACT ISDA calculation
// TODO: Real implementation would handle year boundaries properly
let days = (end - start).num_days() as f64;
let year = start.year();
let is_leap = chrono::NaiveDate::from_ymd_opt(year, 2, 29).is_some();
let year_days = if is_leap { 366.0 } else { 365.0 };

days / year_days
if start >= end {
return 0.0;
}

let mut fraction = 0.0;
let mut current = start;

while current < end {
let current_year = current.year();
let year_end = NaiveDate::from_ymd_opt(current_year + 1, 1, 1).unwrap();
let period_end = end.min(year_end);

let days_in_this_year = (period_end - current).num_days() as f64;
let year_basis = if current.leap_year() { 366.0 } else { 365.0 };

fraction += days_in_this_year / year_basis;
current = year_end;
}

fraction
}

fn act_act_icma_year_fraction(
&self,
start: NaiveDate,
end: NaiveDate,
frequency: i32, // 1=annual, 2=semi, 4=quarterly, 12=monthly
maturity: NaiveDate,
) -> f64 {
if start >= end {
return 0.0;
}

// Generate proper coupon schedule
let coupon_dates = self.generate_coupon_schedule(maturity, frequency);

if coupon_dates.is_empty() {
// Fallback to simple calculation
let days = (end - start).num_days() as f64;
return days / 365.0;
}

let mut total_fraction = 0.0;

// Find overlapping reference periods
for i in 0..coupon_dates.len() - 1 {
let ref_period_start = coupon_dates[i];
let ref_period_end = coupon_dates[i + 1];

// Calculate overlap between [start, end] and reference period
let overlap_start = start.max(ref_period_start);
let overlap_end = end.min(ref_period_end);

if overlap_start < overlap_end {
let days_in_overlap = (overlap_end - overlap_start).num_days() as f64;
let days_in_reference = (ref_period_end - ref_period_start).num_days() as f64;

if days_in_reference > 0.0 {
total_fraction += days_in_overlap / days_in_reference;
}
}
}

total_fraction
}

/// Generate proper coupon schedule working backwards from maturity
fn generate_coupon_schedule(&self, maturity: NaiveDate, frequency: i32) -> Vec<NaiveDate> {
let mut dates = Vec::new();
let mut current = maturity;
dates.push(current);

// Generate up to 50 periods (safety limit)
for _ in 0..50 {
let previous = self.subtract_coupon_period(current, frequency);
if let Some(prev_date) = previous {
dates.push(prev_date);
current = prev_date;
} else {
break;
}
}

// Reverse to get chronological order
dates.reverse();
dates
}

/// Subtract one coupon period from a date, handling month-end conventions
fn subtract_coupon_period(&self, date: NaiveDate, frequency: i32) -> Option<NaiveDate> {
let months_back = 12 / frequency;

let mut new_year = date.year();
let mut new_month = date.month() as i32 - months_back;

// Handle year rollover
while new_month <= 0 {
new_month += 12;
new_year -= 1;
}

let new_day = date.day();

// Try exact day first
if let Some(result) = NaiveDate::from_ymd_opt(new_year, new_month as u32, new_day) {
return Some(result);
}

// Handle month-end cases (e.g., Jan 31 -> Feb 28/29)
// Use last day of month if exact day doesn't exist
let last_day_of_month = match new_month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if date.leap_year() {
29
} else {
28
}
}
_ => 30, // fallback
};
NaiveDate::from_ymd_opt(new_year, new_month as u32, last_day_of_month)
}
}
14 changes: 14 additions & 0 deletions src/fixed_income/traits/day_count.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
use chrono::NaiveDate;

pub trait DayCountConvention {
/// Standard year fraction calculation
fn year_fraction(&self, start: NaiveDate, end: NaiveDate) -> f64;

/// Year fraction with maturity for ICMA and other bond-specific calculations
fn year_fraction_with_maturity(
&self,
start: NaiveDate,
end: NaiveDate,
frequencency: i32,
maturity: NaiveDate,
) -> f64 {
self.year_fraction(start, end)
}

/// Standard day count calculation
fn day_count(&self, start: NaiveDate, end: NaiveDate) -> i32;
}
Loading
Loading