merge coupdaybs, coupdays, coupdaysnc, coupncd, coupnum, couppcd #59

This commit is contained in:
Brian Hung
2025-07-31 15:47:45 -07:00
committed by Nicolás Hatcher
parent 15b67323ed
commit 895fb649da
12 changed files with 694 additions and 42 deletions

View File

@@ -42,36 +42,39 @@ fn is_less_than_one_year(start_date: i64, end_date: i64) -> Result<bool, String>
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
(year % 4 == 0) && (year % 100 != 0 || year % 400 == 0)
}
fn is_last_day_of_feb(date: chrono::NaiveDate) -> bool {
date.month() == 2 && date.day() == if is_leap_year(date.year()) { 29 } else { 28 }
}
fn days360_us(start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 {
let mut d1 = start.day() as i32;
let m1 = start.month() as i32;
let y1 = start.year();
let mut d2 = end.day() as i32;
let mut m2 = end.month() as i32;
let mut y2 = end.year();
let m1 = start.month() as i32;
let m2 = end.month() as i32;
let y1 = start.year();
let y2 = end.year();
if d1 == 31 || (m1 == 2 && (d1 == 29 || (d1 == 28 && !is_leap_year(y1)))) {
// US (NASD) 30/360 method - implementing official specification
// Rule 1: If both date A and B fall on the last day of February, then date B will be changed to the 30th
if is_last_day_of_feb(start) && is_last_day_of_feb(end) {
d2 = 30;
}
// Rule 2: If date A falls on the 31st of a month or last day of February, then date A will be changed to the 30th
if d1 == 31 || is_last_day_of_feb(start) {
d1 = 30;
}
if d2 == 31 {
if d1 != 30 {
d2 = 1;
if m2 == 12 {
y2 += 1;
m2 = 1;
} else {
m2 += 1;
}
} else {
d2 = 30;
}
// Rule 3: If date A falls on the 30th after applying rule 2 and date B falls on the 31st, then date B will be changed to the 30th
if d1 == 30 && d2 == 31 {
d2 = 30;
}
d2 + m2 * 30 + y2 * 360 - d1 - m1 * 30 - y1 * 360
360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1)
}
fn days360_eu(start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 {
@@ -99,12 +102,24 @@ fn days_30us_360(start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 {
let m2 = end.month() as i32;
let y1 = start.year();
let y2 = end.year();
if d1 == 31 {
d1 = 30;
}
if d2 == 31 && (d1 == 30 || d1 == 31) {
// US (NASD) 30/360 method - same as days360_us, implementing official specification
// Rule 1: If both date A and B fall on the last day of February, then date B will be changed to the 30th
if is_last_day_of_feb(start) && is_last_day_of_feb(end) {
d2 = 30;
}
// Rule 2: If date A falls on the 31st of a month or last day of February, then date A will be changed to the 30th
if d1 == 31 || is_last_day_of_feb(start) {
d1 = 30;
}
// Rule 3: If date A falls on the 30th after applying rule 2 and date B falls on the 31st, then date B will be changed to the 30th
if d1 == 30 && d2 == 31 {
d2 = 30;
}
(y2 - y1) * 360 + (m2 - m1) * 30 + (d2 - d1)
}
@@ -177,6 +192,34 @@ fn year_fraction(
Ok(days)
}
fn days_between_dates(start: chrono::NaiveDate, end: chrono::NaiveDate, basis: i32) -> i32 {
match basis {
0 => days360_us(start, end),
1 | 2 => (end - start).num_days() as i32,
3 => (end - start).num_days() as i32,
4 => days360_eu(start, end),
_ => (end - start).num_days() as i32,
}
}
fn coupon_dates(
settlement: chrono::NaiveDate,
maturity: chrono::NaiveDate,
freq: i32,
) -> (chrono::NaiveDate, chrono::NaiveDate) {
let months = 12 / freq;
let step = chrono::Months::new(months as u32);
let mut ncd = maturity;
while let Some(prev) = ncd.checked_sub_months(step) {
if settlement >= prev {
return (prev, ncd);
}
ncd = prev;
}
// Fallback if we somehow exit the loop (shouldn't happen in practice)
(settlement, maturity)
}
fn compute_payment(
rate: f64,
nper: f64,
@@ -2477,6 +2520,316 @@ impl Model {
CalcResult::Number(result)
}
// COUPDAYBS(settlement, maturity, frequency, [basis])
pub(crate) fn fn_coupdaybs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() < 3 || args.len() > 4 {
return CalcResult::new_args_number_error(cell);
}
let settlement = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let maturity = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let frequency = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
};
let basis = if args.len() > 3 {
match self.get_number_no_bools(&args[3], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
}
} else {
0
};
if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) {
return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string());
}
if settlement >= maturity {
return CalcResult::new_error(Error::NUM, cell, "settlement < maturity".to_string());
}
let settlement_date = match from_excel_date(settlement) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let maturity_date = match from_excel_date(maturity) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let (pcd, _) = coupon_dates(settlement_date, maturity_date, frequency);
let days = days_between_dates(pcd, settlement_date, basis);
CalcResult::Number(days as f64)
}
// COUPDAYS(settlement, maturity, frequency, [basis])
pub(crate) fn fn_coupdays(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() < 3 || args.len() > 4 {
return CalcResult::new_args_number_error(cell);
}
let settlement = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let maturity = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let frequency = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
};
let basis = if args.len() > 3 {
match self.get_number_no_bools(&args[3], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
}
} else {
0
};
if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) {
return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string());
}
if settlement >= maturity {
return CalcResult::new_error(Error::NUM, cell, "settlement < maturity".to_string());
}
let settlement_date = match from_excel_date(settlement) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let maturity_date = match from_excel_date(maturity) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let (pcd, ncd) = coupon_dates(settlement_date, maturity_date, frequency);
let days = match basis {
0 | 4 => 360 / frequency, // 30/360 conventions
_ => days_between_dates(pcd, ncd, basis), // Actual day counts
};
CalcResult::Number(days as f64)
}
// COUPDAYSNC(settlement, maturity, frequency, [basis])
pub(crate) fn fn_coupdaysnc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() < 3 || args.len() > 4 {
return CalcResult::new_args_number_error(cell);
}
let settlement = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let maturity = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let frequency = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
};
let basis = if args.len() > 3 {
match self.get_number_no_bools(&args[3], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
}
} else {
0
};
if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) {
return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string());
}
if settlement >= maturity {
return CalcResult::new_error(Error::NUM, cell, "settlement < maturity".to_string());
}
let settlement_date = match from_excel_date(settlement) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let maturity_date = match from_excel_date(maturity) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let (_, ncd) = coupon_dates(settlement_date, maturity_date, frequency);
let days = days_between_dates(settlement_date, ncd, basis);
CalcResult::Number(days as f64)
}
// COUPNCD(settlement, maturity, frequency, [basis])
pub(crate) fn fn_coupncd(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() < 3 || args.len() > 4 {
return CalcResult::new_args_number_error(cell);
}
let settlement = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let maturity = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let frequency = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
};
let basis = if args.len() > 3 {
match self.get_number_no_bools(&args[3], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
}
} else {
0
};
if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) {
return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string());
}
if settlement >= maturity {
return CalcResult::new_error(Error::NUM, cell, "settlement < maturity".to_string());
}
let settlement_date = match from_excel_date(settlement) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let maturity_date = match from_excel_date(maturity) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let (_, ncd) = coupon_dates(settlement_date, maturity_date, frequency);
match crate::formatter::dates::date_to_serial_number(ncd.day(), ncd.month(), ncd.year()) {
Ok(n) => {
if !(MINIMUM_DATE_SERIAL_NUMBER..=MAXIMUM_DATE_SERIAL_NUMBER).contains(&n) {
CalcResult::new_error(Error::NUM, cell, "date out of range".to_string())
} else {
CalcResult::Number(n as f64)
}
}
Err(msg) => CalcResult::new_error(Error::NUM, cell, msg),
}
}
// COUPNUM(settlement, maturity, frequency, [basis])
pub(crate) fn fn_coupnum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() < 3 || args.len() > 4 {
return CalcResult::new_args_number_error(cell);
}
let settlement = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let maturity = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let frequency = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
};
let basis = if args.len() > 3 {
match self.get_number_no_bools(&args[3], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
}
} else {
0
};
if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) {
return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string());
}
if settlement >= maturity {
return CalcResult::new_error(Error::NUM, cell, "settlement < maturity".to_string());
}
let settlement_date = match from_excel_date(settlement) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let maturity_date = match from_excel_date(maturity) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let months = 12 / frequency;
let step = chrono::Months::new(months as u32);
let mut date = maturity_date;
let mut count = 0;
while settlement_date < date {
count += 1;
date = match date.checked_sub_months(step) {
Some(new_date) => new_date,
None => break, // Safety check to avoid infinite loop
};
}
CalcResult::Number(count as f64)
}
// COUPPCD(settlement, maturity, frequency, [basis])
pub(crate) fn fn_couppcd(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() < 3 || args.len() > 4 {
return CalcResult::new_args_number_error(cell);
}
let settlement = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let maturity = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f.trunc() as i64,
Err(s) => return s,
};
let frequency = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
};
let basis = if args.len() > 3 {
match self.get_number_no_bools(&args[3], cell) {
Ok(f) => f.trunc() as i32,
Err(s) => return s,
}
} else {
0
};
if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) {
return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string());
}
if settlement >= maturity {
return CalcResult::new_error(Error::NUM, cell, "settlement < maturity".to_string());
}
let settlement_date = match from_excel_date(settlement) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let maturity_date = match from_excel_date(maturity) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let (pcd, _) = coupon_dates(settlement_date, maturity_date, frequency);
match crate::formatter::dates::date_to_serial_number(pcd.day(), pcd.month(), pcd.year()) {
Ok(n) => {
if !(MINIMUM_DATE_SERIAL_NUMBER..=MAXIMUM_DATE_SERIAL_NUMBER).contains(&n) {
CalcResult::new_error(Error::NUM, cell, "date out of range".to_string())
} else {
CalcResult::Number(n as f64)
}
}
Err(msg) => CalcResult::new_error(Error::NUM, cell, msg),
}
}
// DOLLARDE(fractional_dollar, fraction)
pub(crate) fn fn_dollarde(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 2 {