merge accrint, accrintm #58

This commit is contained in:
Brian Hung
2025-07-31 15:25:13 -07:00
committed by Nicolás Hatcher
parent 050677f905
commit 15b67323ed
9 changed files with 517 additions and 7 deletions

View File

@@ -771,6 +771,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
Function::Duration => args_signature_scalars(arg_count, 5, 1),
Function::Mduration => args_signature_scalars(arg_count, 5, 1),
Function::Pduration => args_signature_scalars(arg_count, 3, 0),
Function::Accrint => args_signature_scalars(arg_count, 6, 2),
Function::Accrintm => args_signature_scalars(arg_count, 4, 1),
Function::Pmt => args_signature_scalars(arg_count, 3, 2),
Function::Ppmt => args_signature_scalars(arg_count, 4, 2),
Function::Price => args_signature_scalars(arg_count, 6, 1),
@@ -1047,6 +1049,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
Function::Duration => not_implemented(args),
Function::Mduration => not_implemented(args),
Function::Pduration => not_implemented(args),
Function::Accrint => not_implemented(args),
Function::Accrintm => not_implemented(args),
Function::Pmt => not_implemented(args),
Function::Ppmt => not_implemented(args),
Function::Price => not_implemented(args),

View File

@@ -92,6 +92,38 @@ fn days360_eu(start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 {
d2 + m2 * 30 + y2 * 360 - d1 - m1 * 30 - y1 * 360
}
fn days_30us_360(start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 {
let mut d1 = start.day() as i32;
let mut d2 = end.day() as i32;
let m1 = start.month() as 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) {
d2 = 30;
}
(y2 - y1) * 360 + (m2 - m1) * 30 + (d2 - d1)
}
fn days_30e_360(start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 {
let mut d1 = start.day() as i32;
let mut d2 = end.day() as i32;
let m1 = start.month() as i32;
let m2 = end.month() as i32;
let y1 = start.year();
let y2 = end.year();
if d1 == 31 {
d1 = 30;
}
if d2 == 31 {
d2 = 30;
}
(y2 - y1) * 360 + (m2 - m1) * 30 + (d2 - d1)
}
fn days_between(start: i64, end: i64, basis: i32) -> Result<i32, String> {
let start_date = from_excel_date(start)?;
let end_date = from_excel_date(end)?;
@@ -129,6 +161,22 @@ fn year_diff(start: i64, end: i64, basis: i32) -> Result<f64, String> {
year_frac(start, end, basis)
}
fn year_fraction(
start: chrono::NaiveDate,
end: chrono::NaiveDate,
basis: i32,
) -> Result<f64, String> {
let days = match basis {
0 => days_30us_360(start, end) as f64 / 360.0,
1 => (end - start).num_days() as f64 / 365.0,
2 => (end - start).num_days() as f64 / 360.0,
3 => (end - start).num_days() as f64 / 365.0,
4 => days_30e_360(start, end) as f64 / 360.0,
_ => return Err("Invalid basis".to_string()),
};
Ok(days)
}
fn compute_payment(
rate: f64,
nper: f64,
@@ -542,6 +590,197 @@ impl Model {
CalcResult::Number(result)
}
// ACCRINT(issue, first_interest, settlement, rate, par, freq, [basis], [calc])
pub(crate) fn fn_accrint(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let arg_count = args.len();
if !(6..=8).contains(&arg_count) {
return CalcResult::new_args_number_error(cell);
}
let issue = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(s) => return s,
};
let first = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(s) => return s,
};
let settlement = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(s) => return s,
};
let rate = match self.get_number_no_bools(&args[3], cell) {
Ok(f) => f,
Err(s) => return s,
};
let par = match self.get_number_no_bools(&args[4], cell) {
Ok(f) => f,
Err(s) => return s,
};
let freq = match self.get_number_no_bools(&args[5], cell) {
Ok(f) => f as i32,
Err(s) => return s,
};
let basis = if arg_count > 6 {
match self.get_number_no_bools(&args[6], cell) {
Ok(f) => f as i32,
Err(s) => return s,
}
} else {
0
};
let calc = if arg_count > 7 {
match self.get_number(&args[7], cell) {
Ok(f) => f != 0.0,
Err(s) => return s,
}
} else {
true
};
if !(freq == 1 || freq == 2 || freq == 4) {
return CalcResult::new_error(Error::NUM, cell, "invalid frequency".to_string());
}
if !(0..=4).contains(&basis) {
return CalcResult::new_error(Error::NUM, cell, "invalid basis".to_string());
}
if par < 0.0 {
return CalcResult::new_error(Error::NUM, cell, "par cannot be negative".to_string());
}
if rate < 0.0 {
return CalcResult::new_error(Error::NUM, cell, "rate cannot be negative".to_string());
}
let issue_d = match from_excel_date(issue as i64) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let first_d = match from_excel_date(first as i64) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let settle_d = match from_excel_date(settlement as i64) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
if settle_d < issue_d {
return CalcResult::new_error(Error::NUM, cell, "settlement < issue".to_string());
}
if first_d < issue_d {
return CalcResult::new_error(Error::NUM, cell, "first_interest < issue".to_string());
}
if settle_d < first_d {
return CalcResult::new_error(
Error::NUM,
cell,
"settlement < first_interest".to_string(),
);
}
let months = 12 / freq;
let mut prev = first_d;
if settle_d <= first_d {
prev = issue_d;
} else {
while prev <= settle_d {
let next = prev + chrono::Months::new(months as u32);
if next > settle_d {
break;
}
prev = next;
}
}
let next_coupon = prev + chrono::Months::new(months as u32);
let mut result = 0.0;
if calc {
let mut next = first_d;
while next < prev {
result += rate * par / freq as f64;
next = next + chrono::Months::new(months as u32);
}
}
let days_in_period = match year_fraction(prev, next_coupon, basis) {
Ok(f) => f,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "invalid basis".to_string()),
};
let days_elapsed = match year_fraction(prev, settle_d, basis) {
Ok(f) => f,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "invalid basis".to_string()),
};
result += rate * par / freq as f64
* if days_in_period == 0.0 {
0.0
} else {
days_elapsed / days_in_period
};
CalcResult::Number(result)
}
// ACCRINTM(issue, settlement, rate, par, [basis])
pub(crate) fn fn_accrintm(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let arg_count = args.len();
if !(4..=5).contains(&arg_count) {
return CalcResult::new_args_number_error(cell);
}
let issue = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(s) => return s,
};
let settlement = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(s) => return s,
};
let rate = match self.get_number_no_bools(&args[2], cell) {
Ok(f) => f,
Err(s) => return s,
};
let par = match self.get_number_no_bools(&args[3], cell) {
Ok(f) => f,
Err(s) => return s,
};
let basis = if arg_count > 4 {
match self.get_number_no_bools(&args[4], cell) {
Ok(f) => f as i32,
Err(s) => return s,
}
} else {
0
};
if !(0..=4).contains(&basis) {
return CalcResult::new_error(Error::NUM, cell, "invalid basis".to_string());
}
if par < 0.0 {
return CalcResult::new_error(Error::NUM, cell, "par cannot be negative".to_string());
}
if rate < 0.0 {
return CalcResult::new_error(Error::NUM, cell, "rate cannot be negative".to_string());
}
let issue_d = match from_excel_date(issue as i64) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
let settle_d = match from_excel_date(settlement as i64) {
Ok(d) => d,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
if settle_d < issue_d {
return CalcResult::new_error(Error::NUM, cell, "settlement < issue".to_string());
}
let frac = match year_fraction(issue_d, settle_d, basis) {
Ok(f) => f,
Err(_) => return CalcResult::new_error(Error::NUM, cell, "invalid basis".to_string()),
};
CalcResult::Number(par * rate * frac)
}
// RATE(nper, pmt, pv, [fv], [type], [guess])
pub(crate) fn fn_rate(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let arg_count = args.len();

View File

@@ -217,6 +217,8 @@ pub enum Function {
Isoweeknum,
// Financial
Accrint,
Accrintm,
Cumipmt,
Cumprinc,
Db,
@@ -325,7 +327,7 @@ pub enum Function {
}
impl Function {
pub fn into_iter() -> IntoIter<Function, 268> {
pub fn into_iter() -> IntoIter<Function, 270> {
[
Function::And,
Function::False,
@@ -486,6 +488,8 @@ impl Function {
Function::WorkdayIntl,
Function::Yearfrac,
Function::Isoweeknum,
Function::Accrint,
Function::Accrintm,
Function::Pmt,
Function::Pv,
Function::Rate,
@@ -837,6 +841,8 @@ impl Function {
"YEARFRAC" => Some(Function::Yearfrac),
"ISOWEEKNUM" | "_XLFN.ISOWEEKNUM" => Some(Function::Isoweeknum),
// Financial
"ACCRINT" => Some(Function::Accrint),
"ACCRINTM" => Some(Function::Accrintm),
"PMT" => Some(Function::Pmt),
"PV" => Some(Function::Pv),
"RATE" => Some(Function::Rate),
@@ -1088,6 +1094,8 @@ impl fmt::Display for Function {
Function::WorkdayIntl => write!(f, "WORKDAY.INTL"),
Function::Yearfrac => write!(f, "YEARFRAC"),
Function::Isoweeknum => write!(f, "ISOWEEKNUM"),
Function::Accrint => write!(f, "ACCRINT"),
Function::Accrintm => write!(f, "ACCRINTM"),
Function::Pmt => write!(f, "PMT"),
Function::Pv => write!(f, "PV"),
Function::Rate => write!(f, "RATE"),
@@ -1377,6 +1385,9 @@ impl Model {
Function::WorkdayIntl => self.fn_workday_intl(args, cell),
Function::Yearfrac => self.fn_yearfrac(args, cell),
Function::Isoweeknum => self.fn_isoweeknum(args, cell),
// Financial
Function::Accrint => self.fn_accrint(args, cell),
Function::Accrintm => self.fn_accrintm(args, cell),
Function::Pmt => self.fn_pmt(args, cell),
Function::Pv => self.fn_pv(args, cell),
Function::Rate => self.fn_rate(args, cell),

View File

@@ -11,6 +11,8 @@ mod test_datedif_leap_month_end;
mod test_days360_month_end;
mod test_degrees_radians;
mod test_error_propagation;
mod test_fn_accrint;
mod test_fn_accrintm;
mod test_fn_average;
mod test_fn_averageifs;
mod test_fn_choose;

View File

@@ -0,0 +1,134 @@
#![allow(clippy::unwrap_used)]
use crate::{cell::CellValue, test::util::new_empty_model};
#[test]
fn fn_accrint() {
let mut model = new_empty_model();
model._set("A1", "=DATE(2020,1,1)");
model._set("A2", "=DATE(2020,1,1)");
model._set("A3", "=DATE(2020,1,31)");
model._set("A4", "10%");
model._set("A5", "$1,000");
model._set("A6", "2");
model._set("B1", "=ACCRINT(A1,A2,A3,A4,A5,A6)");
model._set("C1", "=ACCRINT(A1)");
model._set("C2", "=ACCRINT(A1,A2,A3,A4,A5,3)");
model.evaluate();
match model.get_cell_value_by_ref("Sheet1!B1") {
Ok(CellValue::Number(v)) => {
assert!((v - 8.333333333333334).abs() < 1e-9);
}
other => unreachable!("Expected number for B1, got {:?}", other),
}
assert_eq!(model._get_text("C1"), *"#ERROR!");
assert_eq!(model._get_text("C2"), *"#NUM!");
}
#[test]
fn fn_accrint_parameters() {
let mut model = new_empty_model();
model._set("A1", "=DATE(2020,1,1)");
model._set("A2", "=DATE(2020,1,1)");
model._set("A3", "=DATE(2020,7,1)");
model._set("A4", "8%");
model._set("A5", "1000");
model._set("B1", "=ACCRINT(A1,A2,A3,A4,A5,2,0,TRUE)");
model._set("B2", "=ACCRINT(A1,A2,A3,A4,A5,2,1,TRUE)");
model._set("B3", "=ACCRINT(A1,A2,A3,A4,A5,2,4,TRUE)");
model._set("B4", "=ACCRINT(A1,A2,A3,A4,A5,1)");
model._set("B5", "=ACCRINT(A1,A2,A3,A4,A5,4)");
model._set("B6", "=ACCRINT(A1,A2,A3,A4,A5,2)");
model._set("B7", "=ACCRINT(A1,A2,A3,A4,A5,2,0)");
model.evaluate();
match model.get_cell_value_by_ref("Sheet1!B1") {
Ok(CellValue::Number(v)) => {
assert!((v - 40.0).abs() < 1e-9);
}
other => unreachable!("Expected number for B1, got {:?}", other),
}
match (
model.get_cell_value_by_ref("Sheet1!B1"),
model.get_cell_value_by_ref("Sheet1!B6"),
) {
(Ok(CellValue::Number(v1)), Ok(CellValue::Number(v2))) => {
assert!((v1 - v2).abs() < 1e-12);
}
other => unreachable!("Expected matching numbers, got {:?}", other),
}
}
#[test]
fn fn_accrint_errors() {
let mut model = new_empty_model();
model._set("A1", "=DATE(2020,1,1)");
model._set("A2", "=DATE(2020,1,1)");
model._set("A3", "=DATE(2020,7,1)");
model._set("A4", "8%");
model._set("A5", "1000");
model._set("B1", "=ACCRINT()");
model._set("B2", "=ACCRINT(A1,A2,A3,A4,A5)");
model._set("B3", "=ACCRINT(A1,A2,A3,A4,A5,2,0,TRUE,1)");
model._set("C1", "=ACCRINT(A1,A2,A3,A4,A5,0)");
model._set("C2", "=ACCRINT(A1,A2,A3,A4,A5,3)");
model._set("C3", "=ACCRINT(A1,A2,A3,A4,A5,-1)");
model._set("D1", "=ACCRINT(A1,A2,A3,A4,A5,2,-1)");
model._set("D2", "=ACCRINT(A1,A2,A3,A4,A5,2,5)");
model._set("E1", "=ACCRINT(A3,A2,A1,A4,A5,2)");
model._set("E2", "=ACCRINT(A1,A3,A1,A4,A5,2)");
model._set("F1", "=ACCRINT(A1,A2,A3,A4,0,2)");
model._set("F2", "=ACCRINT(A1,A2,A3,A4,-1000,2)");
model._set("F3", "=ACCRINT(A1,A2,A3,-8%,A5,2)");
model.evaluate();
assert_eq!(model._get_text("B1"), *"#ERROR!");
assert_eq!(model._get_text("B2"), *"#ERROR!");
assert_eq!(model._get_text("B3"), *"#ERROR!");
assert_eq!(model._get_text("C1"), *"#NUM!");
assert_eq!(model._get_text("C2"), *"#NUM!");
assert_eq!(model._get_text("C3"), *"#NUM!");
assert_eq!(model._get_text("D1"), *"#NUM!");
assert_eq!(model._get_text("D2"), *"#NUM!");
assert_eq!(model._get_text("E1"), *"#NUM!");
assert_eq!(model._get_text("E2"), *"#NUM!");
assert_eq!(model._get_text("F2"), *"#NUM!");
assert_eq!(model._get_text("F3"), *"#NUM!");
match model.get_cell_value_by_ref("Sheet1!F1") {
Ok(CellValue::Number(v)) => {
assert!((v - 0.0).abs() < 1e-9);
}
other => unreachable!("Expected 0 for F1, got {:?}", other),
}
}
#[test]
fn fn_accrint_combined() {
let mut model = new_empty_model();
model._set("A1", "=DATE(2018,10,15)");
model._set("A2", "=DATE(2019,2,1)");
model._set("A3", "5%");
model._set("A4", "1000");
model._set("B1", "=ACCRINT(A1,A1,A2,A3,A4,2)");
model.evaluate();
match model.get_cell_value_by_ref("Sheet1!B1") {
Ok(CellValue::Number(v)) => {
assert!((v - 14.722222222222221).abs() < 1e-9);
}
other => unreachable!("Expected number for B1, got {:?}", other),
}
}

View File

@@ -0,0 +1,122 @@
#![allow(clippy::unwrap_used)]
use crate::{cell::CellValue, test::util::new_empty_model};
#[test]
fn fn_accrintm() {
let mut model = new_empty_model();
model._set("A1", "=DATE(2020,1,1)");
model._set("A2", "=DATE(2020,7,1)");
model._set("A3", "10%");
model._set("A4", "$1,000");
model._set("B1", "=ACCRINTM(A1,A2,A3,A4)");
model._set("C1", "=ACCRINTM(A1)");
model.evaluate();
match model.get_cell_value_by_ref("Sheet1!B1") {
Ok(CellValue::Number(v)) => {
assert!((v - 50.0).abs() < 1e-9);
}
other => unreachable!("Expected number for B1, got {:?}", other),
}
assert_eq!(model._get_text("C1"), *"#ERROR!");
}
#[test]
fn fn_accrintm_parameters() {
let mut model = new_empty_model();
model._set("A1", "=DATE(2020,1,1)");
model._set("A2", "=DATE(2020,7,1)");
model._set("A3", "8%");
model._set("A4", "1000");
model._set("B1", "=ACCRINTM(A1,A2,A3,A4,0)");
model._set("B2", "=ACCRINTM(A1,A2,A3,A4,1)");
model._set("B3", "=ACCRINTM(A1,A2,A3,A4,4)");
model._set("C1", "=ACCRINTM(A1,A2,A3,A4)");
model.evaluate();
match (
model.get_cell_value_by_ref("Sheet1!B1"),
model.get_cell_value_by_ref("Sheet1!B2"),
) {
(Ok(CellValue::Number(v1)), Ok(CellValue::Number(v2))) => {
assert!(v1 > 0.0 && v2 > 0.0);
}
other => unreachable!("Expected numbers for basis test, got {:?}", other),
}
match (
model.get_cell_value_by_ref("Sheet1!B1"),
model.get_cell_value_by_ref("Sheet1!C1"),
) {
(Ok(CellValue::Number(v1)), Ok(CellValue::Number(v2))) => {
assert!((v1 - v2).abs() < 1e-12);
}
other => unreachable!(
"Expected matching numbers for default test, got {:?}",
other
),
}
}
#[test]
fn fn_accrintm_errors() {
let mut model = new_empty_model();
model._set("A1", "=DATE(2020,1,1)");
model._set("A2", "=DATE(2020,7,1)");
model._set("A3", "8%");
model._set("A4", "1000");
model._set("B1", "=ACCRINTM()");
model._set("B2", "=ACCRINTM(A1,A2,A3)");
model._set("B3", "=ACCRINTM(A1,A2,A3,A4,0,1)");
model._set("C1", "=ACCRINTM(A1,A2,A3,A4,-1)");
model._set("C2", "=ACCRINTM(A1,A2,A3,A4,5)");
model._set("D1", "=ACCRINTM(A2,A1,A3,A4)");
model._set("E1", "=ACCRINTM(A1,A2,A3,0)");
model._set("E2", "=ACCRINTM(A1,A2,A3,-1000)");
model._set("E3", "=ACCRINTM(A1,A2,-8%,A4)");
model.evaluate();
assert_eq!(model._get_text("B1"), *"#ERROR!");
assert_eq!(model._get_text("B2"), *"#ERROR!");
assert_eq!(model._get_text("B3"), *"#ERROR!");
assert_eq!(model._get_text("C1"), *"#NUM!");
assert_eq!(model._get_text("C2"), *"#NUM!");
assert_eq!(model._get_text("D1"), *"#NUM!");
assert_eq!(model._get_text("E2"), *"#NUM!");
assert_eq!(model._get_text("E3"), *"#NUM!");
match model.get_cell_value_by_ref("Sheet1!E1") {
Ok(CellValue::Number(v)) => {
assert!((v - 0.0).abs() < 1e-9);
}
other => unreachable!("Expected 0 for E1, got {:?}", other),
}
}
#[test]
fn fn_accrintm_combined() {
let mut model = new_empty_model();
model._set("C1", "=DATE(2016,4,5)");
model._set("C2", "=DATE(2019,2,1)");
model._set("A3", "5%");
model._set("A4", "1000");
model._set("B2", "=ACCRINTM(C1,C2,A3,A4)");
model.evaluate();
match model.get_cell_value_by_ref("Sheet1!B2") {
Ok(CellValue::Number(v)) => {
assert!((v - 141.11111111111111).abs() < 1e-9);
}
other => unreachable!("Expected number for B2, got {:?}", other),
}
}