From 23fffdff96601e86453f6f4a515d9e5f524e9e03 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Tue, 3 Feb 2026 02:12:53 -0800 Subject: [PATCH 1/4] Add COUP* financial functions Implements COUPDAYBS, COUPDAYS, COUPDAYSNC, COUPNCD, COUPNUM and COUPPCD with shared date/basis helpers, wires them into dispatch and static analysis, adds parser fallbacks, tests, and docs. --- base/src/expressions/parser/mod.rs | 13 + .../src/expressions/parser/static_analysis.rs | 12 + base/src/functions/financial.rs | 448 +++++++++++++++++- base/src/functions/mod.rs | 26 +- base/src/test/mod.rs | 1 + base/src/test/test_fn_coupon.rs | 260 ++++++++++ docs/src/functions/financial.md | 12 +- docs/src/functions/financial/coupdaybs.md | 3 +- docs/src/functions/financial/coupdays.md | 3 +- docs/src/functions/financial/coupdaysnc.md | 3 +- docs/src/functions/financial/coupncd.md | 3 +- docs/src/functions/financial/coupnum.md | 3 +- docs/src/functions/financial/couppcd.md | 3 +- 13 files changed, 770 insertions(+), 20 deletions(-) create mode 100644 base/src/test/test_fn_coupon.rs diff --git a/base/src/expressions/parser/mod.rs b/base/src/expressions/parser/mod.rs index ed9d28148..b1274a13c 100644 --- a/base/src/expressions/parser/mod.rs +++ b/base/src/expressions/parser/mod.rs @@ -787,6 +787,19 @@ impl<'a> Parser<'a> { args, }; } + let trimmed_name = name.trim_start_matches("_xlfn."); + let fallback_kind = match trimmed_name.to_ascii_uppercase().as_str() { + "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..f994a2dad 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -1007,6 +1007,12 @@ 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::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 +1364,11 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Skew => StaticResult::Scalar, Function::SkewP => StaticResult::Scalar, Function::Small => 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..26650ea87 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,118 @@ 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 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, @@ -1515,6 +1632,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..79c831650 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -315,6 +315,12 @@ pub enum Function { Dollarde, Dollarfr, Effect, + Coupdaybs, + Coupdays, + Coupdaysnc, + Coupncd, + Coupnum, + Couppcd, Fv, Ipmt, Irr, @@ -1073,6 +1079,12 @@ impl Function { Function::Dollarde => functions.dollarde.clone(), Function::Dollarfr => functions.dollarfr.clone(), Function::Effect => functions.effect.clone(), + 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 +1180,7 @@ impl Function { Function::Steyx => functions.steyx.clone(), } } - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -1338,6 +1350,12 @@ impl Function { Function::Rate, Function::Nper, Function::Fv, + Function::Coupdaybs, + Function::Coupdays, + Function::Coupdaysnc, + Function::Coupncd, + Function::Coupnum, + Function::Couppcd, Function::Ppmt, Function::Ipmt, Function::Npv, @@ -1794,6 +1812,12 @@ 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::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..5325e2b7a 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -18,6 +18,7 @@ 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_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..ce552d372 100644 --- a/docs/src/functions/financial.md +++ b/docs/src/functions/financial.md @@ -15,12 +15,12 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | 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/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 From bf4f7778ce2d3fba660b5a445114ab0e78d75b6b Mon Sep 17 00:00:00 2001 From: BrianHung Date: Tue, 3 Feb 2026 10:34:33 -0800 Subject: [PATCH 2/4] Add discount bond financial functions Introduce PRICEDISC, YIELDDISC, DISC, RECEIVED, and INTRATE with shared year-fraction helper, tests, and docs updates. --- base/src/expressions/parser/mod.rs | 5 + .../src/expressions/parser/static_analysis.rs | 10 + base/src/functions/financial.rs | 198 ++++++++++++++++++ base/src/functions/mod.rs | 22 +- base/src/test/mod.rs | 1 + base/src/test/test_fn_discount_bonds.rs | 194 +++++++++++++++++ docs/src/functions/financial.md | 10 +- docs/src/functions/financial/disc.md | 5 +- docs/src/functions/financial/intrate.md | 5 +- docs/src/functions/financial/pricedisc.md | 5 +- docs/src/functions/financial/received.md | 5 +- docs/src/functions/financial/yielddisc.md | 5 +- 12 files changed, 444 insertions(+), 21 deletions(-) create mode 100644 base/src/test/test_fn_discount_bonds.rs diff --git a/base/src/expressions/parser/mod.rs b/base/src/expressions/parser/mod.rs index b1274a13c..017ba7916 100644 --- a/base/src/expressions/parser/mod.rs +++ b/base/src/expressions/parser/mod.rs @@ -789,6 +789,11 @@ impl<'a> Parser<'a> { } let trimmed_name = name.trim_start_matches("_xlfn."); let fallback_kind = match trimmed_name.to_ascii_uppercase().as_str() { + "PRICEDISC" => Some(Function::Pricedisc), + "YIELDDISC" => Some(Function::Yielddisc), + "DISC" => Some(Function::Disc), + "RECEIVED" => Some(Function::Received), + "INTRATE" => Some(Function::Intrate), "COUPDAYBS" => Some(Function::Coupdaybs), "COUPDAYS" => Some(Function::Coupdays), "COUPDAYSNC" => Some(Function::Coupdaysnc), diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index f994a2dad..4e68018d4 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -761,6 +761,11 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 3, 2), Function::Npv => args_signature_npv(arg_count), Function::Pduration => args_signature_scalars(arg_count, 3, 0), + Function::Pricedisc => args_signature_scalars(arg_count, 4, 1), + Function::Yielddisc => args_signature_scalars(arg_count, 4, 1), + Function::Disc => args_signature_scalars(arg_count, 4, 1), + Function::Received => args_signature_scalars(arg_count, 4, 1), + Function::Intrate => 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::Pv => args_signature_scalars(arg_count, 3, 2), @@ -1364,6 +1369,11 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Skew => StaticResult::Scalar, Function::SkewP => StaticResult::Scalar, Function::Small => StaticResult::Scalar, + Function::Pricedisc => StaticResult::Scalar, + Function::Yielddisc => StaticResult::Scalar, + Function::Disc => StaticResult::Scalar, + Function::Received => StaticResult::Scalar, + Function::Intrate => StaticResult::Scalar, Function::Coupdaybs => StaticResult::Scalar, Function::Coupdays => StaticResult::Scalar, Function::Coupdaysnc => StaticResult::Scalar, diff --git a/base/src/functions/financial.rs b/base/src/functions/financial.rs index 26650ea87..89756c4f6 100644 --- a/base/src/functions/financial.rs +++ b/base/src/functions/financial.rs @@ -13,6 +13,7 @@ use super::financial_util::{compute_irr, compute_npv, compute_rate, compute_xirr // Financial calculation constants const DAYS_IN_YEAR_360: i32 = 360; const DAYS_ACTUAL: i32 = 365; +const DAYS_LEAP_YEAR: i32 = 366; const DAYS_IN_MONTH_360: i32 = 30; // See: @@ -50,6 +51,21 @@ fn is_leap_year(year: i32) -> bool { (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0) } +fn days_in_year(date: chrono::NaiveDate, basis: i32) -> Result { + Ok(match basis { + 0 | 2 | 4 => DAYS_IN_YEAR_360, + 1 => { + if is_leap_year(date.year()) { + DAYS_LEAP_YEAR + } else { + DAYS_ACTUAL + } + } + 3 => DAYS_ACTUAL, + _ => return Err("invalid basis".to_string()), + }) +} + fn is_last_day_of_february(date: chrono::NaiveDate) -> bool { date.month() == 2 && date.day() == if is_leap_year(date.year()) { 29 } else { 28 } } @@ -103,6 +119,96 @@ fn days360_eu(start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 { - y1 * DAYS_IN_YEAR_360 } +fn year_frac(start: i64, end: i64, basis: i32) -> Result { + let start_date = from_excel_date(start)?; + let end_date = from_excel_date(end)?; + let days = match basis { + 0 => days360_us(start_date, end_date), + 1..=3 => (end - start) as i32, + 4 => days360_eu(start_date, end_date), + _ => return Err("invalid basis".to_string()), + } as f64; + let year_days = days_in_year(start_date, basis)? as f64; + Ok(days / year_days) +} + +macro_rules! financial_function_with_year_frac { + ( + $args:ident, $self:ident, $cell:ident, + param1_name: $param1_name:literal, + param2_name: $param2_name:literal, + validator: $validator:expr, + formula: |$settlement:ident, $maturity:ident, $param1:ident, $param2:ident, $basis:ident, $year_frac:ident| $formula:expr + ) => {{ + let arg_count = $args.len(); + if !(4..=5).contains(&arg_count) { + return CalcResult::new_args_number_error($cell); + } + + let $settlement = match $self.get_number_no_bools(&$args[0], $cell) { + Ok(f) => f, + Err(s) => return s, + }; + let $maturity = match $self.get_number_no_bools(&$args[1], $cell) { + Ok(f) => f, + Err(s) => return s, + }; + + if $settlement >= $maturity { + return CalcResult::new_error( + Error::NUM, + $cell, + "settlement should be < maturity".to_string(), + ); + } + + if $settlement < MINIMUM_DATE_SERIAL_NUMBER as f64 + || $settlement > MAXIMUM_DATE_SERIAL_NUMBER as f64 + { + return CalcResult::new_error(Error::NUM, $cell, "Invalid number for date".to_string()); + } + if $maturity < MINIMUM_DATE_SERIAL_NUMBER as f64 + || $maturity > MAXIMUM_DATE_SERIAL_NUMBER as f64 + { + return CalcResult::new_error(Error::NUM, $cell, "Invalid number for date".to_string()); + } + + let $param1 = match $self.get_number_no_bools(&$args[2], $cell) { + Ok(p) => p, + Err(err) => return err, + }; + let $param2 = match $self.get_number_no_bools(&$args[3], $cell) { + Ok(p) => p, + Err(err) => return err, + }; + + let $basis = if arg_count > 4 { + match $self.get_number_no_bools(&$args[4], $cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + } + } else { + 0 + }; + + if let Err(msg) = ($validator)($param1, $param2) { + return CalcResult::new_error( + Error::NUM, + $cell, + format!("{} and {}: {}", $param1_name, $param2_name, msg), + ); + } + + let $year_frac = match year_frac($settlement as i64, $maturity as i64, $basis) { + Ok(f) => f, + Err(_) => return CalcResult::new_error(Error::NUM, $cell, "Invalid date".to_string()), + }; + + let result = $formula; + CalcResult::Number(result) + }}; +} + fn days_between_dates(start: chrono::NaiveDate, end: chrono::NaiveDate, basis: i32) -> i32 { match basis { 0 => days360_us(start, end), @@ -1632,6 +1738,98 @@ impl<'a> Model<'a> { CalcResult::Number(result) } + pub(crate) fn fn_pricedisc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + financial_function_with_year_frac!( + args, self, cell, + param1_name: "discount rate", + param2_name: "redemption value", + validator: |discount_rate, redemption_value| { + if discount_rate <= 0.0 || redemption_value <= 0.0 { + Err("values must be positive".to_string()) + } else { + Ok(()) + } + }, + formula: |_settlement, _maturity, discount_rate, redemption_value, _basis, year_frac| { + redemption_value * (1.0 - discount_rate * year_frac) + } + ) + } + + pub(crate) fn fn_yielddisc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + financial_function_with_year_frac!( + args, self, cell, + param1_name: "price", + param2_name: "redemption value", + validator: |price, redemption_value| { + if price <= 0.0 || redemption_value <= 0.0 { + Err("values must be positive".to_string()) + } else { + Ok(()) + } + }, + formula: |_settlement, _maturity, price, redemption_value, _basis, year_frac| { + (redemption_value / price - 1.0) / year_frac + } + ) + } + + pub(crate) fn fn_disc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + financial_function_with_year_frac!( + args, self, cell, + param1_name: "price", + param2_name: "redemption value", + validator: |price, redemption_value| { + if price <= 0.0 || redemption_value <= 0.0 { + Err("values must be positive".to_string()) + } else { + Ok(()) + } + }, + formula: |_settlement, _maturity, price, redemption_value, _basis, year_frac| { + (1.0 - price / redemption_value) / year_frac + } + ) + } + + // RECEIVED(settlement, maturity, investment, discount, [basis]) + pub(crate) fn fn_received(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + financial_function_with_year_frac!( + args, self, cell, + param1_name: "investment", + param2_name: "discount rate", + validator: |investment, discount_rate| { + if investment <= 0.0 || discount_rate <= 0.0 { + Err("values must be positive".to_string()) + } else { + Ok(()) + } + }, + formula: |_settlement, _maturity, investment, discount_rate, _basis, year_frac| { + investment / (1.0 - discount_rate * year_frac) + } + ) + } + + // INTRATE(settlement, maturity, investment, redemption, [basis]) + pub(crate) fn fn_intrate(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + financial_function_with_year_frac!( + args, self, cell, + param1_name: "investment", + param2_name: "redemption value", + validator: |investment, redemption_value| { + if investment <= 0.0 || redemption_value <= 0.0 { + Err("values must be positive".to_string()) + } else { + Ok(()) + } + }, + formula: |_settlement, _maturity, investment, redemption_value, _basis, year_frac| { + ((redemption_value / investment) - 1.0) / year_frac + } + ) + } + // COUPDAYBS(settlement, maturity, frequency, [basis]) pub(crate) fn fn_coupdaybs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let arg_count = args.len(); diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 79c831650..b5334a1f7 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -315,6 +315,11 @@ pub enum Function { Dollarde, Dollarfr, Effect, + Pricedisc, + Yielddisc, + Disc, + Received, + Intrate, Coupdaybs, Coupdays, Coupdaysnc, @@ -1079,6 +1084,11 @@ impl Function { Function::Dollarde => functions.dollarde.clone(), Function::Dollarfr => functions.dollarfr.clone(), Function::Effect => functions.effect.clone(), + Function::Pricedisc => "PRICEDISC".to_string(), + Function::Yielddisc => "YIELDDISC".to_string(), + Function::Disc => "DISC".to_string(), + Function::Received => "RECEIVED".to_string(), + Function::Intrate => "INTRATE".to_string(), Function::Coupdaybs => "COUPDAYBS".to_string(), Function::Coupdays => "COUPDAYS".to_string(), Function::Coupdaysnc => "COUPDAYSNC".to_string(), @@ -1180,7 +1190,7 @@ impl Function { Function::Steyx => functions.steyx.clone(), } } - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -1350,6 +1360,11 @@ impl Function { Function::Rate, Function::Nper, Function::Fv, + Function::Pricedisc, + Function::Yielddisc, + Function::Disc, + Function::Received, + Function::Intrate, Function::Coupdaybs, Function::Coupdays, Function::Coupdaysnc, @@ -1812,6 +1827,11 @@ 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::Pricedisc => self.fn_pricedisc(args, cell), + Function::Yielddisc => self.fn_yielddisc(args, cell), + Function::Disc => self.fn_disc(args, cell), + Function::Received => self.fn_received(args, cell), + Function::Intrate => self.fn_intrate(args, cell), Function::Coupdaybs => self.fn_coupdaybs(args, cell), Function::Coupdays => self.fn_coupdays(args, cell), Function::Coupdaysnc => self.fn_coupdaysnc(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 5325e2b7a..d425ba6a6 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -20,6 +20,7 @@ mod test_fn_concatenate; mod test_fn_count; mod test_fn_coupon; mod test_fn_day; +mod test_fn_discount_bonds; mod test_fn_exact; mod test_fn_financial; mod test_fn_formulatext; diff --git a/base/src/test/test_fn_discount_bonds.rs b/base/src/test/test_fn_discount_bonds.rs new file mode 100644 index 000000000..82137166d --- /dev/null +++ b/base/src/test/test_fn_discount_bonds.rs @@ -0,0 +1,194 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn fn_pricedisc() { + let mut model = new_empty_model(); + model._set("A2", "=DATE(2022,1,25)"); + model._set("A3", "=DATE(2022,11,15)"); + model._set("A4", "3.75%"); + model._set("A5", "100"); + + model._set("B1", "=PRICEDISC(A2,A3,A4,A5)"); + model._set("C1", "=PRICEDISC(A2,A3)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "96.979166667"); + assert_eq!(model._get_text("C1"), *"#ERROR!"); +} + +#[test] +fn fn_yielddisc() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2022,1,25)"); + model._set("A2", "=DATE(2022,11,15)"); + model._set("A3", "97"); + model._set("A4", "100"); + + model._set("B1", "=YIELDDISC(A1,A2,A3,A4)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "0.038393175"); +} + +#[test] +fn fn_disc() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2022,1,25)"); + model._set("A2", "=DATE(2022,11,15)"); + model._set("A3", "97"); + model._set("A4", "100"); + + model._set("B1", "=DISC(A1,A2,A3,A4)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "0.037241379"); +} + +#[test] +fn fn_received() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2020,1,1)"); + model._set("A2", "=DATE(2023,6,30)"); + model._set("A3", "20000"); + model._set("A4", "5%"); + model._set("A5", "3"); + + model._set("B1", "=RECEIVED(A1,A2,A3,A4,A5)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "24236.387782205"); +} + +#[test] +fn fn_intrate() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2020,1,1)"); + model._set("A2", "=DATE(2023,6,30)"); + model._set("A3", "10000"); + model._set("A4", "12000"); + model._set("A5", "3"); + + model._set("B1", "=INTRATE(A1,A2,A3,A4,A5)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "0.057210031"); +} + +#[test] +fn fn_discount_bond_argument_errors() { + let mut model = new_empty_model(); + + model._set("A1", "=PRICEDISC()"); + model._set("A2", "=PRICEDISC(1,2,3)"); + model._set("A3", "=PRICEDISC(1,2,3,4,5,6)"); + + model._set("B1", "=YIELDDISC()"); + model._set("B2", "=YIELDDISC(1,2,3)"); + model._set("B3", "=YIELDDISC(1,2,3,4,5,6)"); + + model._set("C1", "=DISC()"); + model._set("C2", "=DISC(1,2,3)"); + model._set("C3", "=DISC(1,2,3,4,5,6)"); + + model._set("D1", "=RECEIVED()"); + model._set("D2", "=RECEIVED(1,2,3)"); + model._set("D3", "=RECEIVED(1,2,3,4,5,6)"); + + model._set("E1", "=INTRATE()"); + model._set("E2", "=INTRATE(1,2,3)"); + model._set("E3", "=INTRATE(1,2,3,4,5,6)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); + 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"), *"#ERROR!"); + assert_eq!(model._get_text("C2"), *"#ERROR!"); + assert_eq!(model._get_text("C3"), *"#ERROR!"); + assert_eq!(model._get_text("D1"), *"#ERROR!"); + assert_eq!(model._get_text("D2"), *"#ERROR!"); + assert_eq!(model._get_text("D3"), *"#ERROR!"); + assert_eq!(model._get_text("E1"), *"#ERROR!"); + assert_eq!(model._get_text("E2"), *"#ERROR!"); + assert_eq!(model._get_text("E3"), *"#ERROR!"); +} + +#[test] +fn fn_discount_bond_parameter_validation() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2022,1,1)"); + model._set("A2", "=DATE(2022,12,31)"); + + model._set("B1", "=PRICEDISC(A1,A2,0.05,0)"); + model._set("B2", "=PRICEDISC(A1,A2,0,100)"); + model._set("B3", "=PRICEDISC(A1,A2,-0.05,100)"); + + model._set("C1", "=YIELDDISC(A1,A2,0,100)"); + model._set("C2", "=YIELDDISC(A1,A2,95,0)"); + model._set("C3", "=YIELDDISC(A1,A2,-95,100)"); + + model._set("D1", "=DISC(A1,A2,0,100)"); + model._set("D2", "=DISC(A1,A2,95,0)"); + model._set("D3", "=DISC(A1,A2,-95,100)"); + + model._set("E1", "=RECEIVED(A1,A2,0,0.05)"); + model._set("E2", "=RECEIVED(A1,A2,1000,0)"); + model._set("E3", "=RECEIVED(A1,A2,-1000,0.05)"); + + model._set("F1", "=INTRATE(A1,A2,0,1050)"); + model._set("F2", "=INTRATE(A1,A2,1000,0)"); + model._set("F3", "=INTRATE(A1,A2,-1000,1050)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("C1"), *"#NUM!"); + assert_eq!(model._get_text("C2"), *"#NUM!"); +} + +#[test] +fn fn_discount_bond_basis_validation() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2022,1,1)"); + model._set("A2", "=DATE(2022,12,31)"); + + model._set("B1", "=PRICEDISC(A1,A2,0.05,100,0)"); + model._set("B2", "=PRICEDISC(A1,A2,0.05,100,1)"); + model._set("B3", "=PRICEDISC(A1,A2,0.05,100,2)"); + model._set("B4", "=PRICEDISC(A1,A2,0.05,100,3)"); + model._set("B5", "=PRICEDISC(A1,A2,0.05,100,4)"); + + model._set("C1", "=PRICEDISC(A1,A2,0.05,100,-1)"); + model._set("C2", "=PRICEDISC(A1,A2,0.05,100,5)"); + model._set("C3", "=YIELDDISC(A1,A2,95,100,10)"); + model._set("C4", "=DISC(A1,A2,95,100,-5)"); + model._set("C5", "=RECEIVED(A1,A2,1000,0.05,99)"); + model._set("C6", "=INTRATE(A1,A2,1000,1050,-2)"); + + model.evaluate(); + + assert_ne!(model._get_text("B1"), *"#ERROR!"); + assert_ne!(model._get_text("B2"), *"#ERROR!"); + assert_ne!(model._get_text("B3"), *"#ERROR!"); + assert_ne!(model._get_text("B4"), *"#ERROR!"); + assert_ne!(model._get_text("B5"), *"#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("C4"), *"#NUM!"); + assert_eq!(model._get_text("C5"), *"#NUM!"); + assert_eq!(model._get_text("C6"), *"#NUM!"); +} diff --git a/docs/src/functions/financial.md b/docs/src/functions/financial.md index ce552d372..772f48cf3 100644 --- a/docs/src/functions/financial.md +++ b/docs/src/functions/financial.md @@ -25,14 +25,14 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | CUMPRINC | | – | | DB | | – | | DDB | | – | -| DISC | | – | +| DISC | | [DISC](financial/disc) | | DOLLARDE | | – | | DOLLARFR | | – | | DURATION | | – | | EFFECT | | – | | FV | | [FV](financial/fv) | | FVSCHEDULE | | – | -| INTRATE | | – | +| INTRATE | | [INTRATE](financial/intrate) | | IPMT | | – | | IRR | | – | | ISPMT | | – | @@ -49,11 +49,11 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | PMT | | – | | PPMT | | – | | PRICE | | – | -| PRICEDISC | | – | +| PRICEDISC | | [PRICEDISC](financial/pricedisc) | | PRICEMAT | | – | | PV | | [PV](financial/pv) | | RATE | | – | -| RECEIVED | | – | +| RECEIVED | | [RECEIVED](financial/received) | | RRI | | - | | SLN | | – | | SYD | | – | @@ -64,5 +64,5 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | XIRR | | – | | XNPV | | – | | YIELD | | – | -| YIELDDISC | | – | +| YIELDDISC | | [YIELDDISC](financial/yielddisc) | | YIELDMAT | | – | diff --git a/docs/src/functions/financial/disc.md b/docs/src/functions/financial/disc.md index ace252ed5..0e034c07d 100644 --- a/docs/src/functions/financial/disc.md +++ b/docs/src/functions/financial/disc.md @@ -7,6 +7,5 @@ lang: en-US # DISC ::: 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/intrate.md b/docs/src/functions/financial/intrate.md index 4e393f324..7c0b91525 100644 --- a/docs/src/functions/financial/intrate.md +++ b/docs/src/functions/financial/intrate.md @@ -7,6 +7,5 @@ lang: en-US # INTRATE ::: 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/pricedisc.md b/docs/src/functions/financial/pricedisc.md index 9955d058f..331465224 100644 --- a/docs/src/functions/financial/pricedisc.md +++ b/docs/src/functions/financial/pricedisc.md @@ -7,6 +7,5 @@ lang: en-US # PRICEDISC ::: 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/received.md b/docs/src/functions/financial/received.md index 4dd1e7dba..a408adefb 100644 --- a/docs/src/functions/financial/received.md +++ b/docs/src/functions/financial/received.md @@ -7,6 +7,5 @@ lang: en-US # RECEIVED ::: 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/yielddisc.md b/docs/src/functions/financial/yielddisc.md index 547f1c270..ad1510e63 100644 --- a/docs/src/functions/financial/yielddisc.md +++ b/docs/src/functions/financial/yielddisc.md @@ -7,6 +7,5 @@ lang: en-US # YIELDDISC ::: 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). +::: From b9ffcf7e19f92716318c7333bd9a41bce015fc49 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Tue, 3 Feb 2026 10:38:49 -0800 Subject: [PATCH 3/4] Add PRICEMAT and YIELDMAT functions Implement at-maturity price and yield calculations with parser fallback, static analysis, tests, and docs updates. --- base/src/expressions/parser/mod.rs | 2 + .../src/expressions/parser/static_analysis.rs | 4 + base/src/functions/financial.rs | 142 ++++++++++++++++++ base/src/functions/mod.rs | 10 +- base/src/test/mod.rs | 1 + base/src/test/test_fn_pricemat_yieldmat.rs | 101 +++++++++++++ docs/src/functions/financial.md | 4 +- docs/src/functions/financial/pricemat.md | 5 +- docs/src/functions/financial/yieldmat.md | 5 +- 9 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 base/src/test/test_fn_pricemat_yieldmat.rs diff --git a/base/src/expressions/parser/mod.rs b/base/src/expressions/parser/mod.rs index 017ba7916..2194811ae 100644 --- a/base/src/expressions/parser/mod.rs +++ b/base/src/expressions/parser/mod.rs @@ -794,6 +794,8 @@ impl<'a> Parser<'a> { "DISC" => Some(Function::Disc), "RECEIVED" => Some(Function::Received), "INTRATE" => Some(Function::Intrate), + "PRICEMAT" => Some(Function::Pricemat), + "YIELDMAT" => Some(Function::Yieldmat), "COUPDAYBS" => Some(Function::Coupdaybs), "COUPDAYS" => Some(Function::Coupdays), "COUPDAYSNC" => Some(Function::Coupdaysnc), diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 4e68018d4..e4bf1b380 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -766,6 +766,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 4, 1), Function::Received => args_signature_scalars(arg_count, 4, 1), Function::Intrate => args_signature_scalars(arg_count, 4, 1), + Function::Pricemat => args_signature_scalars(arg_count, 5, 1), + Function::Yieldmat => args_signature_scalars(arg_count, 5, 1), Function::Pmt => args_signature_scalars(arg_count, 3, 2), Function::Ppmt => args_signature_scalars(arg_count, 4, 2), Function::Pv => args_signature_scalars(arg_count, 3, 2), @@ -1374,6 +1376,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Disc => StaticResult::Scalar, Function::Received => StaticResult::Scalar, Function::Intrate => StaticResult::Scalar, + Function::Pricemat => StaticResult::Scalar, + Function::Yieldmat => StaticResult::Scalar, Function::Coupdaybs => StaticResult::Scalar, Function::Coupdays => StaticResult::Scalar, Function::Coupdaysnc => StaticResult::Scalar, diff --git a/base/src/functions/financial.rs b/base/src/functions/financial.rs index 89756c4f6..608338191 100644 --- a/base/src/functions/financial.rs +++ b/base/src/functions/financial.rs @@ -1830,6 +1830,148 @@ impl<'a> Model<'a> { ) } + pub(crate) fn fn_pricemat(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(5..=6).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + + let settlement = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let maturity = match self.get_number_no_bools(&args[1], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let issue = 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 yld = match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let basis = if arg_count == 6 { + match self.get_number_no_bools(&args[5], cell) { + Ok(f) => f, + Err(s) => return s, + } + } else { + 0.0 + }; + if rate < 0.0 || yld < 0.0 || settlement >= maturity { + return CalcResult::new_error(Error::NUM, cell, "invalid parameters".to_string()); + } + if settlement < MINIMUM_DATE_SERIAL_NUMBER as f64 + || maturity > MAXIMUM_DATE_SERIAL_NUMBER as f64 + || settlement > MAXIMUM_DATE_SERIAL_NUMBER as f64 + || maturity < MINIMUM_DATE_SERIAL_NUMBER as f64 + || issue < MINIMUM_DATE_SERIAL_NUMBER as f64 + || issue > MAXIMUM_DATE_SERIAL_NUMBER as f64 + { + return CalcResult::new_error(Error::NUM, cell, "Invalid number for date".to_string()); + } + let issue_to_maturity_frac = match year_frac(issue as i64, maturity as i64, basis as i32) { + Ok(f) => f, + Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()), + }; + let issue_to_settlement_frac = + match year_frac(issue as i64, settlement as i64, basis as i32) { + Ok(f) => f, + Err(_) => { + return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()) + } + }; + let settlement_to_maturity_frac = + match year_frac(settlement as i64, maturity as i64, basis as i32) { + Ok(f) => f, + Err(_) => { + return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()) + } + }; + let mut result = 1.0 + issue_to_maturity_frac * rate; + result /= 1.0 + settlement_to_maturity_frac * yld; + result -= issue_to_settlement_frac * rate; + result *= 100.0; + CalcResult::Number(result) + } + + pub(crate) fn fn_yieldmat(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(5..=6).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + + let settlement = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let maturity = match self.get_number_no_bools(&args[1], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let issue = 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 price = match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let basis = if arg_count == 6 { + match self.get_number_no_bools(&args[5], cell) { + Ok(f) => f, + Err(s) => return s, + } + } else { + 0.0 + }; + if price <= 0.0 || rate < 0.0 || settlement >= maturity || settlement < issue { + return CalcResult::new_error(Error::NUM, cell, "invalid parameters".to_string()); + } + if settlement < MINIMUM_DATE_SERIAL_NUMBER as f64 + || maturity > MAXIMUM_DATE_SERIAL_NUMBER as f64 + || settlement > MAXIMUM_DATE_SERIAL_NUMBER as f64 + || maturity < MINIMUM_DATE_SERIAL_NUMBER as f64 + || issue < MINIMUM_DATE_SERIAL_NUMBER as f64 + || issue > MAXIMUM_DATE_SERIAL_NUMBER as f64 + { + return CalcResult::new_error(Error::NUM, cell, "Invalid number for date".to_string()); + } + let issue_to_maturity_frac = match year_frac(issue as i64, maturity as i64, basis as i32) { + Ok(f) => f, + Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()), + }; + let issue_to_settlement_frac = + match year_frac(issue as i64, settlement as i64, basis as i32) { + Ok(f) => f, + Err(_) => { + return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()) + } + }; + let settlement_to_maturity_frac = + match year_frac(settlement as i64, maturity as i64, basis as i32) { + Ok(f) => f, + Err(_) => { + return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()) + } + }; + let mut y = 1.0 + issue_to_maturity_frac * rate; + y /= price / 100.0 + issue_to_settlement_frac * rate; + y -= 1.0; + y /= settlement_to_maturity_frac; + CalcResult::Number(y) + } + // COUPDAYBS(settlement, maturity, frequency, [basis]) pub(crate) fn fn_coupdaybs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let arg_count = args.len(); diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index b5334a1f7..f59c4ec63 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -320,6 +320,8 @@ pub enum Function { Disc, Received, Intrate, + Pricemat, + Yieldmat, Coupdaybs, Coupdays, Coupdaysnc, @@ -1089,6 +1091,8 @@ impl Function { Function::Disc => "DISC".to_string(), Function::Received => "RECEIVED".to_string(), Function::Intrate => "INTRATE".to_string(), + Function::Pricemat => "PRICEMAT".to_string(), + Function::Yieldmat => "YIELDMAT".to_string(), Function::Coupdaybs => "COUPDAYBS".to_string(), Function::Coupdays => "COUPDAYS".to_string(), Function::Coupdaysnc => "COUPDAYSNC".to_string(), @@ -1190,7 +1194,7 @@ impl Function { Function::Steyx => functions.steyx.clone(), } } - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -1365,6 +1369,8 @@ impl Function { Function::Disc, Function::Received, Function::Intrate, + Function::Pricemat, + Function::Yieldmat, Function::Coupdaybs, Function::Coupdays, Function::Coupdaysnc, @@ -1832,6 +1838,8 @@ impl<'a> Model<'a> { Function::Disc => self.fn_disc(args, cell), Function::Received => self.fn_received(args, cell), Function::Intrate => self.fn_intrate(args, cell), + Function::Pricemat => self.fn_pricemat(args, cell), + Function::Yieldmat => self.fn_yieldmat(args, cell), Function::Coupdaybs => self.fn_coupdaybs(args, cell), Function::Coupdays => self.fn_coupdays(args, cell), Function::Coupdaysnc => self.fn_coupdaysnc(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index d425ba6a6..7694d6ca0 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -28,6 +28,7 @@ mod test_fn_if; mod test_fn_maxifs; mod test_fn_minifs; mod test_fn_or_xor; +mod test_fn_pricemat_yieldmat; mod test_fn_product; mod test_fn_rept; mod test_fn_sum; diff --git a/base/src/test/test_fn_pricemat_yieldmat.rs b/base/src/test/test_fn_pricemat_yieldmat.rs new file mode 100644 index 000000000..f8d9d5635 --- /dev/null +++ b/base/src/test/test_fn_pricemat_yieldmat.rs @@ -0,0 +1,101 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn fn_pricemat() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2019,2,15)"); + model._set("A2", "=DATE(2025,4,13)"); + model._set("A3", "=DATE(2018,11,11)"); + model._set("A4", "5.75%"); + model._set("A5", "6.5%"); + + model._set("B1", "=PRICEMAT(A1,A2,A3,A4,A5)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "96.271187821"); +} + +#[test] +fn fn_yieldmat() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2019,2,15)"); + model._set("A2", "=DATE(2025,4,13)"); + model._set("A3", "=DATE(2018,11,11)"); + model._set("A4", "5.75%"); + model._set("A5", "96.27"); + + model._set("B1", "=YIELDMAT(A1,A2,A3,A4,A5)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "0.065002762"); +} + +#[test] +fn fn_pricemat_yieldmat_argument_errors() { + let mut model = new_empty_model(); + + model._set("A1", "=PRICEMAT()"); + model._set("A2", "=PRICEMAT(1,2,3,4)"); + model._set("A3", "=PRICEMAT(1,2,3,4,5,6,7)"); + + model._set("B1", "=YIELDMAT()"); + model._set("B2", "=YIELDMAT(1,2,3,4)"); + model._set("B3", "=YIELDMAT(1,2,3,4,5,6,7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); + assert_eq!(model._get_text("B3"), *"#ERROR!"); +} + +#[test] +fn fn_pricemat_yieldmat_date_ordering() { + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2022,1,1)"); // settlement + model._set("A2", "=DATE(2021,12,31)"); // maturity (before settlement) + model._set("A3", "=DATE(2020,1,1)"); // issue + model._set("A4", "=DATE(2023,1,1)"); // later issue date + + model._set("B1", "=PRICEMAT(A1,A2,A3,0.06,0.05)"); + model._set("B2", "=YIELDMAT(A1,A2,A3,0.06,99)"); + model._set("C1", "=PRICEMAT(A1,A2,A4,0.06,0.05)"); + model._set("C2", "=YIELDMAT(A1,A2,A4,0.06,99)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("C1"), *"#NUM!"); + assert_eq!(model._get_text("C2"), *"#NUM!"); +} + +#[test] +fn fn_pricemat_yieldmat_parameter_validation() { + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2022,1,1)"); + model._set("A2", "=DATE(2022,12,31)"); + model._set("A3", "=DATE(2021,1,1)"); + + model._set("B1", "=PRICEMAT(A1,A2,A3,-0.06,0.05)"); + model._set("B2", "=PRICEMAT(A1,A2,A3,0.06,-0.05)"); + + model._set("C1", "=YIELDMAT(A1,A2,A3,0.06,0)"); + model._set("C2", "=YIELDMAT(A1,A2,A3,-0.06,99)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("C1"), *"#NUM!"); + assert_eq!(model._get_text("C2"), *"#NUM!"); +} diff --git a/docs/src/functions/financial.md b/docs/src/functions/financial.md index 772f48cf3..93f342300 100644 --- a/docs/src/functions/financial.md +++ b/docs/src/functions/financial.md @@ -50,7 +50,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | PPMT | | – | | PRICE | | – | | PRICEDISC | | [PRICEDISC](financial/pricedisc) | -| PRICEMAT | | – | +| PRICEMAT | | [PRICEMAT](financial/pricemat) | | PV | | [PV](financial/pv) | | RATE | | – | | RECEIVED | | [RECEIVED](financial/received) | @@ -65,4 +65,4 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | XNPV | | – | | YIELD | | – | | YIELDDISC | | [YIELDDISC](financial/yielddisc) | -| YIELDMAT | | – | +| YIELDMAT | | [YIELDMAT](financial/yieldmat) | diff --git a/docs/src/functions/financial/pricemat.md b/docs/src/functions/financial/pricemat.md index 9cc61c926..26923b8d0 100644 --- a/docs/src/functions/financial/pricemat.md +++ b/docs/src/functions/financial/pricemat.md @@ -7,6 +7,5 @@ lang: en-US # PRICEMAT ::: 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/yieldmat.md b/docs/src/functions/financial/yieldmat.md index 483fef3bb..ba3f94a51 100644 --- a/docs/src/functions/financial/yieldmat.md +++ b/docs/src/functions/financial/yieldmat.md @@ -7,6 +7,5 @@ lang: en-US # YIELDMAT ::: 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). +::: From 7561c32e902ccd24ac012bfc8d48c729bbb27272 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Thu, 5 Feb 2026 23:11:17 -0800 Subject: [PATCH 4/4] Align PRICEMAT validation with YIELDMAT Reject settlements before issue date to match YIELDMAT and Excel validation rules. --- base/src/functions/financial.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/functions/financial.rs b/base/src/functions/financial.rs index 608338191..6fabffa1c 100644 --- a/base/src/functions/financial.rs +++ b/base/src/functions/financial.rs @@ -1864,7 +1864,7 @@ impl<'a> Model<'a> { } else { 0.0 }; - if rate < 0.0 || yld < 0.0 || settlement >= maturity { + if rate < 0.0 || yld < 0.0 || settlement >= maturity || settlement < issue { return CalcResult::new_error(Error::NUM, cell, "invalid parameters".to_string()); } if settlement < MINIMUM_DATE_SERIAL_NUMBER as f64