Skip to content

Commit 2b7d75f

Browse files
feat(fixed_income): implement zero-coupon bond pricing (#75)
* Add FI module definition * Add trait module for FI * Add PriceResult struct * Add FI types and custom pricing errors * Add ZeroCouponBond struct and implement Bond trait for pricing * Update day_count to include year_fraction and day_count methods * Add TODOs * Refactor ZeroCouponBond pricing logic to use DayCountConvention * Refactor bond module documentation and add test cases for ZeroCouponBond * Remove out unused future bond imports * Add basic tests * Add cashflow tests for schedule generation and price result validation * Remove unused imports and bond_price function stub from bond_pricing.rs * Update README and add Zero Coupon Bond example * update zero coupon bond tests and add day count tests * add maturity handling to FI day_counts * fix linting * add validation tests for ZeroCouponBond pricing errors * update tests * remove unused leap year check * update icma daycount tests
1 parent f5ec864 commit 2b7d75f

File tree

9 files changed

+643
-67
lines changed

9 files changed

+643
-67
lines changed

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
Quantrs is a tiny quantitative finance library for Rust.
2323
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.
24-
The library is still in the early stages of development, and many features are not yet implemented.
24+
The library is still in the early stages of development and many features are not yet implemented.
2525

2626
Please check out the documentation [here][docs-url].
2727

@@ -86,6 +86,18 @@ Quantrs supports options pricing with various models for both vanilla and exotic
8686
8787
</details>
8888

89+
### Fixed Income
90+
91+
- Bond Types
92+
- [x] _Zero-Coupon Bonds_
93+
- [ ] _Treasury Bonds_ (fixed-rate coupon)
94+
- [ ] _Corporate Bonds_ (fixed-rate coupon with credit spreads)
95+
- [ ] _Floating-Rate Bonds_ (variable coupon with caps/floors)
96+
- [ ] Duration (_Macaulay_, _Modified_, _Effective_)
97+
- [ ] Convexity
98+
- [ ] Yield Measures (_YTM_, _YTC_, _YTW_)
99+
- [x] Day Count Conventions (_ACT/365F_, _ACT/365_, _ACT/360_, _30/360 US_, _30/360 Eurobond_, _ACT/ACT ISDA_, _ACT/ACT ICMA_)
100+
89101
## Usage
90102

91103
Add this to your `Cargo.toml`:

examples/fixed_income.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
use quantrs::fixed_income::{Bond, DayCount, ZeroCouponBond};
2+
3+
fn main() {
4+
let face_value = 1000.0;
5+
let maturity = chrono::NaiveDate::from_ymd_opt(2030, 1, 1).unwrap_or_default();
6+
let settlement = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).unwrap_or_default();
7+
let ytm = 0.05; // 5% yield to maturity
8+
let day_count = DayCount::ActActICMA;
9+
10+
let zero_coupon_bond = ZeroCouponBond::new(face_value, maturity);
11+
12+
match zero_coupon_bond.price(settlement, ytm, day_count) {
13+
Ok(price_result) => {
14+
println!("Clean Price: {:.2}", price_result.clean);
15+
println!("Dirty Price: {:.2}", price_result.dirty);
16+
println!("Accrued Interest: {:.2}", price_result.accrued);
17+
}
18+
Err(e) => {
19+
eprintln!("Error pricing bond: {}", e);
20+
}
21+
}
22+
}

src/fixed_income.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
//! - [Zero-Coupon Bonds](bonds/struct.ZeroCouponBond.html)
1919
2020
pub use self::bond_pricing::*;
21+
pub use self::bonds::*;
2122
pub use self::cashflow::*;
2223
pub use self::types::*;
2324
pub use traits::*;

src/fixed_income/bonds.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
//! Module for various bond .
1+
//! Module for various bond types.
22
33
// pub use corporate::CorporateBond;
44
// pub use floating_rate::FloatingRateBond;
55
// pub use treasury::TreasuryBond;
6-
// pub use zero_coupon::ZeroCouponBond;
6+
pub use zero_coupon::ZeroCouponBond;
77

88
// mod corporate;
99
// mod floating_rate;
1010
// mod treasury;
11-
// mod zero_coupon;
11+
mod zero_coupon;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/// Zero Coupon Bond implementation
2+
///
3+
/// Example:
4+
///
5+
/// use quantrs::fixed_income::{Bond, DayCount, ZeroCouponBond};
6+
/// fn main() {
7+
/// let face_value = 1000.0;
8+
/// let maturity = chrono::NaiveDate::from_ymd_opt(2030, 1, 1).unwrap_or_default();
9+
/// let settlement = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).unwrap_or_default();
10+
/// let ytm = 0.05; // 5% yield to maturity
11+
/// let day_count = DayCount::ActActICMA;
12+
/// let zero_coupon_bond = ZeroCouponBond::new(face_value, maturity);
13+
/// match zero_coupon_bond.price(settlement, ytm, day_count) {
14+
/// Ok(price_result) => {
15+
/// println!("Clean Price: {:.2}", price_result.clean);
16+
/// println!("Dirty Price: {:.2}", price_result.dirty);
17+
/// println!("Accrued Interest: {:.2}", price_result.accrued);
18+
/// }
19+
/// Err(e) => {
20+
/// eprintln!("Error pricing bond: {}", e);
21+
/// }
22+
/// }
23+
/// }
24+
///
25+
/// Note: Zero coupon bonds do not have accrued interest.
26+
///
27+
/// # References
28+
/// - Fabozzi, Frank J. "Bond Markets, Analysis and Strategies." 9th Edition. Pearson, 2013.
29+
/// - https://dqydj.com/zero-coupon-bond-calculator
30+
use crate::fixed_income::{Bond, BondPricingError, DayCount, PriceResult};
31+
use chrono::NaiveDate;
32+
33+
#[derive(Debug, Clone)]
34+
pub struct ZeroCouponBond {
35+
pub face_value: f64,
36+
pub maturity: NaiveDate,
37+
}
38+
39+
impl ZeroCouponBond {
40+
pub fn new(face_value: f64, maturity: NaiveDate) -> Self {
41+
Self {
42+
face_value,
43+
maturity,
44+
}
45+
}
46+
}
47+
48+
impl Bond for ZeroCouponBond {
49+
fn price(
50+
&self,
51+
settlement: NaiveDate,
52+
ytm: f64,
53+
day_count: DayCount,
54+
) -> Result<PriceResult, BondPricingError> {
55+
if ytm < 0.0 {
56+
return Err(BondPricingError::invalid_yield(ytm));
57+
}
58+
59+
if settlement >= self.maturity {
60+
return Err(BondPricingError::settlement_after_maturity(
61+
settlement,
62+
self.maturity,
63+
));
64+
}
65+
66+
let years_to_maturity = crate::fixed_income::DayCountConvention::year_fraction(
67+
&day_count,
68+
settlement,
69+
self.maturity,
70+
);
71+
72+
let clean_price = self.face_value / (1.0 + ytm).powf(years_to_maturity);
73+
let accrued = self.accrued_interest(settlement, day_count);
74+
let dirty_price = clean_price;
75+
76+
Ok(PriceResult::new(clean_price, dirty_price, accrued))
77+
}
78+
79+
fn accrued_interest(&self, _settlement: NaiveDate, _day_count: DayCount) -> f64 {
80+
// Zero coupon bonds have no accrued interest
81+
0.0
82+
}
83+
}

src/fixed_income/day_count.rs

Lines changed: 164 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
1-
use crate::fixed_income::{DayCount, DayCountConvention};
1+
/// Implementations of various day count conventions for fixed income calculations.
2+
///
3+
/// References:
4+
/// - https://www.isda.org/2011/01/07/act-act-icma
5+
/// - https://www.isda.org/a/NIJEE/ICMA-Rule-Book-Rule-251-reproduced-by-permission-of-ICMA.pdf
6+
/// - https://quant.stackexchange.com/questions/71858
7+
/// - https://www.investopedia.com/terms/d/daycountconvention.asp
8+
/// - https://en.wikipedia.org/wiki/Day_count_convention
9+
/// - https://support.treasurysystems.com/support/solutions/articles/103000058036-day-count-conventions
10+
use crate::{
11+
fixed_income::{DayCount, DayCountConvention},
12+
log_warn,
13+
};
214
use chrono::{Datelike, NaiveDate};
315

416
impl DayCountConvention for DayCount {
517
fn year_fraction(&self, start: NaiveDate, end: NaiveDate) -> f64 {
6-
let days = self.day_count(start, end) as f64;
7-
818
match self {
9-
DayCount::Act365F => days / 365.0,
10-
DayCount::Act360 => days / 360.0,
11-
DayCount::Act365 => days / 365.0,
12-
DayCount::Thirty360US => days / 360.0,
13-
DayCount::Thirty360E => days / 360.0,
14-
DayCount::ActActISDA => {
15-
// More complex calculation for actual/actual ISDA
16-
self.act_act_isda_year_fraction(start, end)
19+
DayCount::Act365F => self.day_count(start, end) as f64 / 365.0,
20+
DayCount::Act360 => self.day_count(start, end) as f64 / 360.0,
21+
DayCount::Act365 => {
22+
let is_leap = chrono::NaiveDate::from_ymd_opt(start.year(), 2, 29).is_some();
23+
let year_days = if is_leap { 366.0 } else { 365.0 };
24+
self.day_count(start, end) as f64 / year_days
1725
}
26+
DayCount::Thirty360US => self.day_count(start, end) as f64 / 360.0,
27+
DayCount::Thirty360E => self.day_count(start, end) as f64 / 360.0,
28+
DayCount::ActActISDA => self.act_act_isda_year_fraction(start, end),
1829
DayCount::ActActICMA => {
19-
// ICMA method - requires coupon frequency
20-
days / 365.0 // TODO: Simplified
30+
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.");
31+
self.act_act_icma_year_fraction(start, end, 2, end)
2132
}
2233
}
2334
}
@@ -33,6 +44,23 @@ impl DayCountConvention for DayCount {
3344
DayCount::ActActICMA => (end - start).num_days() as i32,
3445
}
3546
}
47+
48+
fn year_fraction_with_maturity(
49+
&self,
50+
start: NaiveDate,
51+
end: NaiveDate,
52+
frequency: i32,
53+
maturity: NaiveDate,
54+
) -> f64 {
55+
match self {
56+
DayCount::ActActICMA => {
57+
// For simplified implementation, assume semi-annual frequency
58+
// In real usage, this would come from bond parameters
59+
self.act_act_icma_year_fraction(start, end, frequency, maturity)
60+
}
61+
_ => self.year_fraction(start, end),
62+
}
63+
}
3664
}
3765

3866
impl DayCount {
@@ -75,13 +103,128 @@ impl DayCount {
75103
}
76104

77105
fn act_act_isda_year_fraction(&self, start: NaiveDate, end: NaiveDate) -> f64 {
78-
// Simplified ACT/ACT ISDA calculation
79-
// TODO: Real implementation would handle year boundaries properly
80-
let days = (end - start).num_days() as f64;
81-
let year = start.year();
82-
let is_leap = chrono::NaiveDate::from_ymd_opt(year, 2, 29).is_some();
83-
let year_days = if is_leap { 366.0 } else { 365.0 };
84-
85-
days / year_days
106+
if start >= end {
107+
return 0.0;
108+
}
109+
110+
let mut fraction = 0.0;
111+
let mut current = start;
112+
113+
while current < end {
114+
let current_year = current.year();
115+
let year_end = NaiveDate::from_ymd_opt(current_year + 1, 1, 1).unwrap();
116+
let period_end = end.min(year_end);
117+
118+
let days_in_this_year = (period_end - current).num_days() as f64;
119+
let year_basis = if current.leap_year() { 366.0 } else { 365.0 };
120+
121+
fraction += days_in_this_year / year_basis;
122+
current = year_end;
123+
}
124+
125+
fraction
126+
}
127+
128+
fn act_act_icma_year_fraction(
129+
&self,
130+
start: NaiveDate,
131+
end: NaiveDate,
132+
frequency: i32, // 1=annual, 2=semi, 4=quarterly, 12=monthly
133+
maturity: NaiveDate,
134+
) -> f64 {
135+
if start >= end {
136+
return 0.0;
137+
}
138+
139+
// Generate proper coupon schedule
140+
let coupon_dates = self.generate_coupon_schedule(maturity, frequency);
141+
142+
if coupon_dates.is_empty() {
143+
// Fallback to simple calculation
144+
let days = (end - start).num_days() as f64;
145+
return days / 365.0;
146+
}
147+
148+
let mut total_fraction = 0.0;
149+
150+
// Find overlapping reference periods
151+
for i in 0..coupon_dates.len() - 1 {
152+
let ref_period_start = coupon_dates[i];
153+
let ref_period_end = coupon_dates[i + 1];
154+
155+
// Calculate overlap between [start, end] and reference period
156+
let overlap_start = start.max(ref_period_start);
157+
let overlap_end = end.min(ref_period_end);
158+
159+
if overlap_start < overlap_end {
160+
let days_in_overlap = (overlap_end - overlap_start).num_days() as f64;
161+
let days_in_reference = (ref_period_end - ref_period_start).num_days() as f64;
162+
163+
if days_in_reference > 0.0 {
164+
total_fraction += days_in_overlap / days_in_reference;
165+
}
166+
}
167+
}
168+
169+
total_fraction
170+
}
171+
172+
/// Generate proper coupon schedule working backwards from maturity
173+
fn generate_coupon_schedule(&self, maturity: NaiveDate, frequency: i32) -> Vec<NaiveDate> {
174+
let mut dates = Vec::new();
175+
let mut current = maturity;
176+
dates.push(current);
177+
178+
// Generate up to 50 periods (safety limit)
179+
for _ in 0..50 {
180+
let previous = self.subtract_coupon_period(current, frequency);
181+
if let Some(prev_date) = previous {
182+
dates.push(prev_date);
183+
current = prev_date;
184+
} else {
185+
break;
186+
}
187+
}
188+
189+
// Reverse to get chronological order
190+
dates.reverse();
191+
dates
192+
}
193+
194+
/// Subtract one coupon period from a date, handling month-end conventions
195+
fn subtract_coupon_period(&self, date: NaiveDate, frequency: i32) -> Option<NaiveDate> {
196+
let months_back = 12 / frequency;
197+
198+
let mut new_year = date.year();
199+
let mut new_month = date.month() as i32 - months_back;
200+
201+
// Handle year rollover
202+
while new_month <= 0 {
203+
new_month += 12;
204+
new_year -= 1;
205+
}
206+
207+
let new_day = date.day();
208+
209+
// Try exact day first
210+
if let Some(result) = NaiveDate::from_ymd_opt(new_year, new_month as u32, new_day) {
211+
return Some(result);
212+
}
213+
214+
// Handle month-end cases (e.g., Jan 31 -> Feb 28/29)
215+
// Use last day of month if exact day doesn't exist
216+
let last_day_of_month = match new_month {
217+
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
218+
4 | 6 | 9 | 11 => 30,
219+
2 => {
220+
if date.leap_year() {
221+
29
222+
} else {
223+
28
224+
}
225+
}
226+
_ => 30, // fallback
227+
};
228+
NaiveDate::from_ymd_opt(new_year, new_month as u32, last_day_of_month)
86229
}
87230
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
use chrono::NaiveDate;
22

33
pub trait DayCountConvention {
4+
/// Standard year fraction calculation
45
fn year_fraction(&self, start: NaiveDate, end: NaiveDate) -> f64;
6+
7+
/// Year fraction with maturity for ICMA and other bond-specific calculations
8+
fn year_fraction_with_maturity(
9+
&self,
10+
start: NaiveDate,
11+
end: NaiveDate,
12+
frequencency: i32,
13+
maturity: NaiveDate,
14+
) -> f64 {
15+
self.year_fraction(start, end)
16+
}
17+
18+
/// Standard day count calculation
519
fn day_count(&self, start: NaiveDate, end: NaiveDate) -> i32;
620
}

0 commit comments

Comments
 (0)