diff --git a/base/src/expressions/parser/mod.rs b/base/src/expressions/parser/mod.rs index ed9d28148..103b204b8 100644 --- a/base/src/expressions/parser/mod.rs +++ b/base/src/expressions/parser/mod.rs @@ -787,6 +787,21 @@ impl<'a> Parser<'a> { args, }; } + let trimmed_name = name.trim_start_matches("_xlfn."); + let fallback_kind = match trimmed_name.to_ascii_uppercase().as_str() { + "ACCRINT" => Some(Function::Accrint), + "ACCRINTM" => Some(Function::Accrintm), + "COUPDAYBS" => Some(Function::Coupdaybs), + "COUPDAYS" => Some(Function::Coupdays), + "COUPDAYSNC" => Some(Function::Coupdaysnc), + "COUPNCD" => Some(Function::Coupncd), + "COUPNUM" => Some(Function::Coupnum), + "COUPPCD" => Some(Function::Couppcd), + _ => None, + }; + if let Some(kind) = fallback_kind { + return Node::FunctionKind { kind, args }; + } return Node::InvalidFunctionKind { name, args }; } let context = &self.context; diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 04edf5614..01150d169 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -1007,6 +1007,14 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec vec![Signature::Vector; arg_count], Function::SkewP => vec![Signature::Vector; arg_count], Function::Small => vec![Signature::Vector, Signature::Scalar], + Function::Accrint => args_signature_scalars(arg_count, 6, 2), + Function::Accrintm => args_signature_scalars(arg_count, 4, 1), + Function::Coupdaybs => args_signature_scalars(arg_count, 3, 1), + Function::Coupdays => args_signature_scalars(arg_count, 3, 1), + Function::Coupdaysnc => args_signature_scalars(arg_count, 3, 1), + Function::Coupncd => args_signature_scalars(arg_count, 3, 1), + Function::Coupnum => args_signature_scalars(arg_count, 3, 1), + Function::Couppcd => args_signature_scalars(arg_count, 3, 1), } } @@ -1358,5 +1366,13 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Skew => StaticResult::Scalar, Function::SkewP => StaticResult::Scalar, Function::Small => StaticResult::Scalar, + Function::Accrint => StaticResult::Scalar, + Function::Accrintm => StaticResult::Scalar, + Function::Coupdaybs => StaticResult::Scalar, + Function::Coupdays => StaticResult::Scalar, + Function::Coupdaysnc => StaticResult::Scalar, + Function::Coupncd => StaticResult::Scalar, + Function::Coupnum => StaticResult::Scalar, + Function::Couppcd => StaticResult::Scalar, } } diff --git a/base/src/functions/financial.rs b/base/src/functions/financial.rs index ec1c986d6..6f14a0da9 100644 --- a/base/src/functions/financial.rs +++ b/base/src/functions/financial.rs @@ -10,13 +10,18 @@ use crate::{ use super::financial_util::{compute_irr, compute_npv, compute_rate, compute_xirr, compute_xnpv}; +// Financial calculation constants +const DAYS_IN_YEAR_360: i32 = 360; +const DAYS_ACTUAL: i32 = 365; +const DAYS_IN_MONTH_360: i32 = 30; + // See: // https://github.com/apache/openoffice/blob/c014b5f2b55cff8d4b0c952d5c16d62ecde09ca1/main/scaddins/source/analysis/financial.cxx fn is_less_than_one_year(start_date: i64, end_date: i64) -> Result { let end = from_excel_date(end_date)?; let start = from_excel_date(start_date)?; - if end_date - start_date < 365 { + if end_date - start_date < DAYS_ACTUAL as i64 { return Ok(true); } let end_year = end.year(); @@ -41,6 +46,134 @@ fn is_less_than_one_year(start_date: i64, end_date: i64) -> Result Ok(end_day <= start_day) } +fn is_leap_year(year: i32) -> bool { + (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0) +} + +fn is_last_day_of_february(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 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(); + + // 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_february(start) && is_last_day_of_february(end) { + d2 = DAYS_IN_MONTH_360; + } + + // 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_february(start) { + d1 = DAYS_IN_MONTH_360; + } + + // 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 == DAYS_IN_MONTH_360 && d2 == 31 { + d2 = DAYS_IN_MONTH_360; + } + + DAYS_IN_YEAR_360 * (y2 - y1) + DAYS_IN_MONTH_360 * (m2 - m1) + (d2 - d1) +} + +fn days360_eu(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 = DAYS_IN_MONTH_360; + } + if d2 == 31 { + d2 = DAYS_IN_MONTH_360; + } + + d2 + m2 * DAYS_IN_MONTH_360 + y2 * DAYS_IN_YEAR_360 + - d1 + - m1 * DAYS_IN_MONTH_360 + - y1 * DAYS_IN_YEAR_360 +} + +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 year_fraction( + start: chrono::NaiveDate, + end: chrono::NaiveDate, + basis: i32, +) -> Result { + let days = match basis { + 0 => days360_us(start, end) as f64 / DAYS_IN_YEAR_360 as f64, + 1 => (end - start).num_days() as f64 / DAYS_ACTUAL as f64, + 2 => (end - start).num_days() as f64 / DAYS_IN_YEAR_360 as f64, + 3 => (end - start).num_days() as f64 / DAYS_ACTUAL as f64, + 4 => days360_eu(start, end) as f64 / DAYS_IN_YEAR_360 as f64, + _ => return Err("Invalid basis".to_string()), + }; + Ok(days) +} + +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 next_coupon_date = maturity; + while let Some(prev) = next_coupon_date.checked_sub_months(step) { + if settlement >= prev { + return (prev, next_coupon_date); + } + next_coupon_date = prev; + } + // Fallback if we somehow exit the loop (shouldn't happen in practice) + (settlement, maturity) +} + +fn convert_date_serial( + date_serial: f64, + cell: CellReferenceIndex, +) -> Result { + match from_excel_date(date_serial as i64) { + Ok(date) => Ok(date), + Err(_) => Err(CalcResult::new_error( + Error::NUM, + cell, + "Invalid date".to_string(), + )), + } +} + +fn date_to_serial_with_validation(date: chrono::NaiveDate, cell: CellReferenceIndex) -> CalcResult { + match crate::formatter::dates::date_to_serial_number(date.day(), date.month(), date.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), + } +} + fn compute_payment( rate: f64, nper: f64, @@ -454,6 +587,204 @@ impl<'a> Model<'a> { CalcResult::Number(result) } + // ACCRINT(issue, first_interest, settlement, rate, par, freq, [basis], [calc]) + // ACCRINT(issue, first_interest, settlement, rate, par, frequency, [basis], [calc_method]) + 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); + } + + // Parse required arguments + let issue = match self.get_number(&args[0], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let first = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let settlement = match self.get_number(&args[2], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let rate = match self.get_number(&args[3], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let par = match self.get_number(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let freq = match self.get_number(&args[5], cell) { + Ok(f) => f as i32, + Err(s) => return s, + }; + + // Parse optional arguments + let basis = if arg_count > 6 { + match self.get_number(&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 convert_date_serial(issue, cell) { + Ok(d) => d, + Err(err) => return err, + }; + 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 convert_date_serial(settlement, cell) { + Ok(d) => d, + Err(err) => return err, + }; + + if settle_d <= issue_d { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement must be after issue".to_string(), + ); + } + if first_d < issue_d { + return CalcResult::new_error(Error::NUM, cell, "first_interest < issue".to_string()); + } + // Note: settlement CAN be before first_interest - this is valid (buying before first coupon) + + 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); + } + + // Parse required arguments + let issue = match self.get_number(&args[0], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let settlement = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let rate = match self.get_number(&args[2], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let par = match self.get_number(&args[3], cell) { + Ok(f) => f, + Err(s) => return s, + }; + + // Parse optional argument + let basis = if arg_count > 4 { + match self.get_number(&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 convert_date_serial(issue, cell) { + Ok(d) => d, + Err(err) => return err, + }; + let settle_d = match convert_date_serial(settlement, cell) { + Ok(d) => d, + Err(err) => return err, + }; + + 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(); @@ -1515,6 +1846,335 @@ impl<'a> Model<'a> { CalcResult::Number(result) } + // COUPDAYBS(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_coupdaybs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + 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 arg_count > 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 should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + let (prev_coupon_date, _) = coupon_dates(settlement_date, maturity_date, frequency); + let days = days_between_dates(prev_coupon_date, 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 { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + 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 arg_count > 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 should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + let (prev_coupon_date, next_coupon_date) = + coupon_dates(settlement_date, maturity_date, frequency); + let days = match basis { + 0 | 4 => DAYS_IN_YEAR_360 / frequency, // 30/360 conventions + _ => days_between_dates(prev_coupon_date, next_coupon_date, 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 { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + 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 arg_count > 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 should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + let (_, next_coupon_date) = coupon_dates(settlement_date, maturity_date, frequency); + let days = days_between_dates(settlement_date, next_coupon_date, basis); + CalcResult::Number(days as f64) + } + + // COUPNCD(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_coupncd(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + 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 arg_count > 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 should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + let (_, next_coupon_date) = coupon_dates(settlement_date, maturity_date, frequency); + date_to_serial_with_validation(next_coupon_date, cell) + } + + // COUPNUM(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_coupnum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + 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 arg_count > 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 should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + 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 { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + 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 arg_count > 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 should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + let (prev_coupon_date, _) = coupon_dates(settlement_date, maturity_date, frequency); + date_to_serial_with_validation(prev_coupon_date, cell) + } + // DOLLARDE(fractional_dollar, fraction) pub(crate) fn fn_dollarde(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() != 2 { diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 3a8edf115..4b15dad84 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -315,6 +315,14 @@ pub enum Function { Dollarde, Dollarfr, Effect, + Accrint, + Accrintm, + Coupdaybs, + Coupdays, + Coupdaysnc, + Coupncd, + Coupnum, + Couppcd, Fv, Ipmt, Irr, @@ -1073,6 +1081,14 @@ impl Function { Function::Dollarde => functions.dollarde.clone(), Function::Dollarfr => functions.dollarfr.clone(), Function::Effect => functions.effect.clone(), + Function::Accrint => "ACCRINT".to_string(), + Function::Accrintm => "ACCRINTM".to_string(), + Function::Coupdaybs => "COUPDAYBS".to_string(), + Function::Coupdays => "COUPDAYS".to_string(), + Function::Coupdaysnc => "COUPDAYSNC".to_string(), + Function::Coupncd => "COUPNCD".to_string(), + Function::Coupnum => "COUPNUM".to_string(), + Function::Couppcd => "COUPPCD".to_string(), Function::Fv => functions.fv.clone(), Function::Ipmt => functions.ipmt.clone(), Function::Irr => functions.irr.clone(), @@ -1168,7 +1184,7 @@ impl Function { Function::Steyx => functions.steyx.clone(), } } - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -1338,6 +1354,14 @@ impl Function { Function::Rate, Function::Nper, Function::Fv, + Function::Accrint, + Function::Accrintm, + Function::Coupdaybs, + Function::Coupdays, + Function::Coupdaysnc, + Function::Coupncd, + Function::Coupnum, + Function::Couppcd, Function::Ppmt, Function::Ipmt, Function::Npv, @@ -1794,6 +1818,14 @@ impl<'a> Model<'a> { Function::Rate => self.fn_rate(args, cell), Function::Nper => self.fn_nper(args, cell), Function::Fv => self.fn_fv(args, cell), + Function::Accrint => self.fn_accrint(args, cell), + Function::Accrintm => self.fn_accrintm(args, cell), + Function::Coupdaybs => self.fn_coupdaybs(args, cell), + Function::Coupdays => self.fn_coupdays(args, cell), + Function::Coupdaysnc => self.fn_coupdaysnc(args, cell), + Function::Coupncd => self.fn_coupncd(args, cell), + Function::Coupnum => self.fn_coupnum(args, cell), + Function::Couppcd => self.fn_couppcd(args, cell), Function::Ppmt => self.fn_ppmt(args, cell), Function::Ipmt => self.fn_ipmt(args, cell), Function::Npv => self.fn_npv(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 3c2b0f052..f4b174f23 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -13,11 +13,13 @@ 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_average; mod test_fn_averageifs; mod test_fn_choose; mod test_fn_concatenate; mod test_fn_count; +mod test_fn_coupon; mod test_fn_day; mod test_fn_exact; mod test_fn_financial; diff --git a/base/src/test/test_fn_accrint.rs b/base/src/test/test_fn_accrint.rs new file mode 100644 index 000000000..5aeb7f7cf --- /dev/null +++ b/base/src/test/test_fn_accrint.rs @@ -0,0 +1,222 @@ +#![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), + } +} + +/// Test the exact Excel documentation example for ACCRINT +/// https://support.microsoft.com/en-us/office/accrint-function-fe45d089-6722-4fb3-9379-e1f911d8dc74 +/// Issue: 39508 (March 1, 2008) +/// First interest: 39691 (August 31, 2008) +/// Settlement: 39569 (May 1, 2008) +/// Rate: 0.1 (10%) +/// Par: 1000 +/// Frequency: 2 (semiannual) +/// Basis: 0 +/// Expected result: 16.666667 +#[test] +fn fn_accrint_excel_docs_example() { + let mut model = new_empty_model(); + model._set("A1", "=ACCRINT(39508,39691,39569,0.1,1000,2,0)"); + model.evaluate(); + + match model.get_cell_value_by_ref("Sheet1!A1") { + Ok(CellValue::Number(v)) => { + assert!( + (v - 16.666666666666668).abs() < 1e-6, + "Expected ~16.666667, got {}", + v + ); + } + other => unreachable!("Expected number for A1, got {:?}", other), + } +} + +#[test] +fn fn_accrintm_basic() { + 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_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), + } +} diff --git a/base/src/test/test_fn_coupon.rs b/base/src/test/test_fn_coupon.rs new file mode 100644 index 000000000..4c1fb0eee --- /dev/null +++ b/base/src/test/test_fn_coupon.rs @@ -0,0 +1,260 @@ +#![allow(clippy::unwrap_used)] + +use crate::{cell::CellValue, test::util::new_empty_model}; + +#[test] +fn fn_coupon_functions() { + let mut model = new_empty_model(); + + // Test with basis 1 (original test) + model._set("A1", "=DATE(2001,1,25)"); + model._set("A2", "=DATE(2001,11,15)"); + model._set("B1", "=COUPDAYBS(A1,A2,2,1)"); + model._set("B2", "=COUPDAYS(A1,A2,2,1)"); + model._set("B3", "=COUPDAYSNC(A1,A2,2,1)"); + model._set("B4", "=COUPNCD(A1,A2,2,1)"); + model._set("B5", "=COUPNUM(A1,A2,2,1)"); + model._set("B6", "=COUPPCD(A1,A2,2,1)"); + + // Test with basis 3 for better coverage + model._set("C1", "=COUPDAYBS(DATE(2001,1,25),DATE(2001,11,15),2,3)"); + model._set("C2", "=COUPDAYS(DATE(2001,1,25),DATE(2001,11,15),2,3)"); + model._set("C3", "=COUPDAYSNC(DATE(2001,1,25),DATE(2001,11,15),2,3)"); + model._set("C4", "=COUPNCD(DATE(2001,1,25),DATE(2001,11,15),2,3)"); + model._set("C5", "=COUPNUM(DATE(2007,1,25),DATE(2008,11,15),2,1)"); + model._set("C6", "=COUPPCD(DATE(2001,1,25),DATE(2001,11,15),2,3)"); + + model.evaluate(); + + // Test basis 1 + assert_eq!(model._get_text("B1"), "71"); + assert_eq!(model._get_text("B2"), "181"); + assert_eq!(model._get_text("B3"), "110"); + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B4"), + Ok(CellValue::Number(37026.0)) + ); + assert_eq!(model._get_text("B5"), "2"); + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B6"), + Ok(CellValue::Number(36845.0)) + ); + + // Test basis 3 (more comprehensive coverage) + assert_eq!(model._get_text("C1"), "71"); + assert_eq!(model._get_text("C2"), "181"); // Fixed: actual days + assert_eq!(model._get_text("C3"), "110"); + assert_eq!(model._get_text("C4"), "37026"); + assert_eq!(model._get_text("C5"), "4"); + assert_eq!(model._get_text("C6"), "36845"); +} + +#[test] +fn fn_coupon_functions_error_cases() { + let mut model = new_empty_model(); + + // Test invalid frequency + model._set("E1", "=COUPDAYBS(DATE(2001,1,25),DATE(2001,11,15),3,1)"); + // Test invalid basis + model._set("E2", "=COUPDAYS(DATE(2001,1,25),DATE(2001,11,15),2,5)"); + // Test settlement >= maturity + model._set("E3", "=COUPDAYSNC(DATE(2001,11,15),DATE(2001,1,25),2,1)"); + // Test too few arguments + model._set("E4", "=COUPNCD(DATE(2001,1,25),DATE(2001,11,15))"); + // Test too many arguments + model._set("E5", "=COUPNUM(DATE(2001,1,25),DATE(2001,11,15),2,1,1)"); + + model.evaluate(); + + // All should return errors + assert_eq!(model._get_text("E1"), "#NUM!"); + assert_eq!(model._get_text("E2"), "#NUM!"); + assert_eq!(model._get_text("E3"), "#NUM!"); + assert_eq!(model._get_text("E4"), *"#ERROR!"); + assert_eq!(model._get_text("E5"), *"#ERROR!"); +} + +#[test] +fn fn_coupdays_actual_day_count_fix() { + // Verify COUPDAYS correctly distinguishes between fixed vs actual day count methods + // Bug: basis 2&3 were incorrectly using fixed calculations like basis 0&4 + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2023,1,15)"); + model._set("A2", "=DATE(2023,7,15)"); + + model._set("B1", "=COUPDAYS(A1,A2,2,0)"); // 30/360: uses 360/freq + model._set("B2", "=COUPDAYS(A1,A2,2,2)"); // Actual/360: uses actual days + model._set("B3", "=COUPDAYS(A1,A2,2,3)"); // Actual/365: uses actual days + model._set("B4", "=COUPDAYS(A1,A2,2,4)"); // 30/360 European: uses 360/freq + + model.evaluate(); + + // Basis 0&4: theoretical 360/2 = 180 days + assert_eq!(model._get_text("B1"), "180"); + assert_eq!(model._get_text("B4"), "180"); + + // Basis 2&3: actual days between Jan 15 and Jul 15 = 181 days + assert_eq!(model._get_text("B2"), "181"); + assert_eq!(model._get_text("B3"), "181"); +} + +// ============================================================================= +// FEBRUARY EDGE CASE TESTS - Day Count Convention Compliance +// ============================================================================= +// These tests verify that financial functions correctly handle February dates +// according to the official 30/360 day count convention specifications. + +#[test] +fn test_coupon_functions_february_consistency() { + let mut model = new_empty_model(); + + // Test that coupon functions behave consistently between US and European methods + // when February dates are involved + + // Settlement: Last day of February (non-leap year) + // Maturity: Some date in following year that creates a clear test case + model._set("A1", "=DATE(2023,2,28)"); // Last day of Feb, non-leap year + model._set("A2", "=DATE(2024,2,28)"); // Same day next year + + // Test COUPDAYS with different basis values + model._set("B1", "=COUPDAYS(A1,A2,2,0)"); // US 30/360 - should treat Feb 28 as day 30 + model._set("B2", "=COUPDAYS(A1,A2,2,4)"); // European 30/360 - should treat Feb 28 as day 28 + model._set("B3", "=COUPDAYS(A1,A2,2,1)"); // Actual/actual - should use real days + + model.evaluate(); + + // All should return valid numbers (no errors) + assert_ne!(model._get_text("B1"), *"#NUM!"); + assert_ne!(model._get_text("B2"), *"#NUM!"); + assert_ne!(model._get_text("B3"), *"#NUM!"); + + // US and European 30/360 should potentially give different results for February dates + // (though the exact difference depends on the specific coupon calculation logic) + let us_result = model._get_text("B1"); + let european_result = model._get_text("B2"); + let actual_result = model._get_text("B3"); + + // Verify all are numeric + assert!(us_result.parse::().is_ok()); + assert!(european_result.parse::().is_ok()); + assert!(actual_result.parse::().is_ok()); +} + +#[test] +fn test_february_edge_cases_leap_vs_nonleap() { + let mut model = new_empty_model(); + + // Test leap year vs non-leap year February handling + + // Feb 28 in non-leap year (this IS the last day of February) + model._set("A1", "=DATE(2023,2,28)"); + model._set("A2", "=DATE(2023,8,28)"); + + // Feb 28 in leap year (this is NOT the last day of February) + model._set("A3", "=DATE(2024,2,28)"); + model._set("A4", "=DATE(2024,8,28)"); + + // Feb 29 in leap year (this IS the last day of February) + model._set("A5", "=DATE(2024,2,29)"); + model._set("A6", "=DATE(2024,8,29)"); + + // Test with basis 0 (US 30/360) - should have special February handling + model._set("B1", "=COUPDAYS(A1,A2,2,0)"); // Feb 28 non-leap (last day) + model._set("B2", "=COUPDAYS(A3,A4,2,0)"); // Feb 28 leap year (not last day) + model._set("B3", "=COUPDAYS(A5,A6,2,0)"); // Feb 29 leap year (last day) + + model.evaluate(); + + // All should succeed + assert_ne!(model._get_text("B1"), *"#NUM!"); + assert_ne!(model._get_text("B2"), *"#NUM!"); + assert_ne!(model._get_text("B3"), *"#NUM!"); + + // Verify they're all numeric + assert!(model._get_text("B1").parse::().is_ok()); + assert!(model._get_text("B2").parse::().is_ok()); + assert!(model._get_text("B3").parse::().is_ok()); +} + +#[test] +fn test_us_nasd_both_february_rule() { + let mut model = new_empty_model(); + + // Test the specific US/NASD rule: "If both date A and B fall on the last day of February, + // then date B will be changed to the 30th" + + // Case 1: Both dates are Feb 28 in non-leap years (both are last day of February) + model._set("A1", "=DATE(2023,2,28)"); // Last day of Feb 2023 + model._set("A2", "=DATE(2025,2,28)"); // Last day of Feb 2025 + + // Case 2: Both dates are Feb 29 in leap years (both are last day of February) + model._set("A3", "=DATE(2024,2,29)"); // Last day of Feb 2024 + model._set("A4", "=DATE(2028,2,29)"); // Last day of Feb 2028 + + // Case 3: Mixed - Feb 28 non-leap to Feb 29 leap (both are last day of February) + model._set("A5", "=DATE(2023,2,28)"); // Last day of Feb 2023 + model._set("A6", "=DATE(2024,2,29)"); // Last day of Feb 2024 + + // Case 4: Control - Feb 28 in leap year (NOT last day) to Feb 29 (IS last day) + model._set("A7", "=DATE(2024,2,28)"); // NOT last day of Feb 2024 + model._set("A8", "=DATE(2024,2,29)"); // IS last day of Feb 2024 + + // Test using coupon functions that should apply US/NASD 30/360 (basis 0) + model._set("B1", "=COUPDAYS(A1,A2,1,0)"); // Both last day Feb - Rule 1 should apply + model._set("B2", "=COUPDAYS(A3,A4,1,0)"); // Both last day Feb - Rule 1 should apply + model._set("B3", "=COUPDAYS(A5,A6,1,0)"); // Both last day Feb - Rule 1 should apply + model._set("B4", "=COUPDAYS(A7,A8,1,0)"); // Only end is last day Feb - Rule 1 should NOT apply + + // Compare with European method (basis 4) - should behave differently + model._set("C1", "=COUPDAYS(A1,A2,1,4)"); // European - no special Feb handling + model._set("C2", "=COUPDAYS(A3,A4,1,4)"); // European - no special Feb handling + model._set("C3", "=COUPDAYS(A5,A6,1,4)"); // European - no special Feb handling + model._set("C4", "=COUPDAYS(A7,A8,1,4)"); // European - no special Feb handling + + model.evaluate(); + + // All should succeed without errors + for row in ["B1", "B2", "B3", "B4", "C1", "C2", "C3", "C4"] { + assert_ne!(model._get_text(row), *"#NUM!", "Failed for {row}"); + assert!( + model._get_text(row).parse::().is_ok(), + "Non-numeric result for {row}" + ); + } +} + +#[test] +fn test_coupon_functions_february_edge_cases() { + let mut model = new_empty_model(); + + // Test that coupon functions handle February dates correctly without errors + + // Settlement: February 28, 2023 (non-leap), Maturity: February 28, 2024 (leap) + model._set("A1", "=DATE(2023,2,28)"); + model._set("A2", "=DATE(2024,2,28)"); + + // Test with basis 0 (US 30/360 - should use special February handling) + model._set("B1", "=COUPDAYBS(A1,A2,2,0)"); + model._set("B2", "=COUPDAYS(A1,A2,2,0)"); + model._set("B3", "=COUPDAYSNC(A1,A2,2,0)"); + + // Test with basis 4 (European 30/360 - should NOT use special February handling) + model._set("C1", "=COUPDAYBS(A1,A2,2,4)"); + model._set("C2", "=COUPDAYS(A1,A2,2,4)"); + model._set("C3", "=COUPDAYSNC(A1,A2,2,4)"); + + model.evaluate(); + + // With US method (basis 0), February dates should be handled specially + // With European method (basis 4), February dates should use actual dates + // Key point: both should work without errors + + // We're ensuring functions complete successfully with February dates + assert_ne!(model._get_text("B1"), *"#NUM!"); + assert_ne!(model._get_text("B2"), *"#NUM!"); + assert_ne!(model._get_text("B3"), *"#NUM!"); + assert_ne!(model._get_text("C1"), *"#NUM!"); + assert_ne!(model._get_text("C2"), *"#NUM!"); + assert_ne!(model._get_text("C3"), *"#NUM!"); +} diff --git a/docs/src/functions/financial.md b/docs/src/functions/financial.md index e8593cdf2..314556287 100644 --- a/docs/src/functions/financial.md +++ b/docs/src/functions/financial.md @@ -11,16 +11,16 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | Function | Status | Documentation | | ---------- | ---------------------------------------------- | ------------------ | -| ACCRINT | | – | -| ACCRINTM | | – | +| ACCRINT | | [ACCRINT](financial/accrint) | +| ACCRINTM | | [ACCRINTM](financial/accrintm) | | AMORDEGRC | | – | | AMORLINC | | – | -| COUPDAYBS | | – | -| COUPDAYS | | – | -| COUPDAYSNC | | – | -| COUPNCD | | – | -| COUPNUM | | – | -| COUPPCD | | – | +| COUPDAYBS | | [COUPDAYBS](financial/coupdaybs) | +| COUPDAYS | | [COUPDAYS](financial/coupdays) | +| COUPDAYSNC | | [COUPDAYSNC](financial/coupdaysnc) | +| COUPNCD | | [COUPNCD](financial/coupncd) | +| COUPNUM | | [COUPNUM](financial/coupnum) | +| COUPPCD | | [COUPPCD](financial/couppcd) | | CUMIPMT | | – | | CUMPRINC | | – | | DB | | – | diff --git a/docs/src/functions/financial/accrint.md b/docs/src/functions/financial/accrint.md index 453b66c44..dd7ef500e 100644 --- a/docs/src/functions/financial/accrint.md +++ b/docs/src/functions/financial/accrint.md @@ -7,6 +7,5 @@ lang: en-US # ACCRINT ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) -::: \ No newline at end of file +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). +::: diff --git a/docs/src/functions/financial/accrintm.md b/docs/src/functions/financial/accrintm.md index 0de1e3f6d..c64ab8808 100644 --- a/docs/src/functions/financial/accrintm.md +++ b/docs/src/functions/financial/accrintm.md @@ -7,6 +7,5 @@ lang: en-US # ACCRINTM ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) -::: \ No newline at end of file +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). +::: diff --git a/docs/src/functions/financial/coupdaybs.md b/docs/src/functions/financial/coupdaybs.md index 710809b68..287bf6cd8 100644 --- a/docs/src/functions/financial/coupdaybs.md +++ b/docs/src/functions/financial/coupdaybs.md @@ -7,6 +7,5 @@ lang: en-US # COUPDAYBS ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/financial/coupdays.md b/docs/src/functions/financial/coupdays.md index b47d8e8c5..a9c53692c 100644 --- a/docs/src/functions/financial/coupdays.md +++ b/docs/src/functions/financial/coupdays.md @@ -7,6 +7,5 @@ lang: en-US # COUPDAYS ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/financial/coupdaysnc.md b/docs/src/functions/financial/coupdaysnc.md index 1aabcf855..1b8b961b9 100644 --- a/docs/src/functions/financial/coupdaysnc.md +++ b/docs/src/functions/financial/coupdaysnc.md @@ -7,6 +7,5 @@ lang: en-US # COUPDAYSNC ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/financial/coupncd.md b/docs/src/functions/financial/coupncd.md index 4c3eeb24a..6715a8dd7 100644 --- a/docs/src/functions/financial/coupncd.md +++ b/docs/src/functions/financial/coupncd.md @@ -7,6 +7,5 @@ lang: en-US # COUPNCD ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/financial/coupnum.md b/docs/src/functions/financial/coupnum.md index 7bd4538e9..74cd68332 100644 --- a/docs/src/functions/financial/coupnum.md +++ b/docs/src/functions/financial/coupnum.md @@ -7,6 +7,5 @@ lang: en-US # COUPNUM ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/financial/couppcd.md b/docs/src/functions/financial/couppcd.md index 933c0efd2..16d3fe1c3 100644 --- a/docs/src/functions/financial/couppcd.md +++ b/docs/src/functions/financial/couppcd.md @@ -7,6 +7,5 @@ lang: en-US # COUPPCD ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file