diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 2f04dc0..2dc2fd2 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -783,6 +783,13 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 3, 0), Function::Tbillyield => args_signature_scalars(arg_count, 3, 0), Function::Yield => args_signature_scalars(arg_count, 6, 1), + Function::Pricedisc => args_signature_scalars(arg_count, 4, 1), + Function::Pricemat => args_signature_scalars(arg_count, 5, 1), + Function::Yielddisc => args_signature_scalars(arg_count, 4, 1), + Function::Yieldmat => args_signature_scalars(arg_count, 5, 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::Xirr => args_signature_xirr(arg_count), Function::Xnpv => args_signature_xnpv(arg_count), Function::Besseli => args_signature_scalars(arg_count, 2, 0), @@ -1052,6 +1059,13 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Tbillprice => not_implemented(args), Function::Tbillyield => not_implemented(args), Function::Yield => not_implemented(args), + Function::Pricedisc => not_implemented(args), + Function::Pricemat => not_implemented(args), + Function::Yielddisc => not_implemented(args), + Function::Yieldmat => not_implemented(args), + Function::Disc => not_implemented(args), + Function::Received => not_implemented(args), + Function::Intrate => not_implemented(args), Function::Xirr => not_implemented(args), Function::Xnpv => not_implemented(args), Function::Besseli => scalar_arguments(args), diff --git a/base/src/functions/financial.rs b/base/src/functions/financial.rs index 037ae63..ca47640 100644 --- a/base/src/functions/financial.rs +++ b/base/src/functions/financial.rs @@ -41,6 +41,94 @@ 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 days360_us(start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 { + let mut d1 = start.day() as i32; + let m1 = start.month() as i32; + let y1 = start.year(); + let mut d2 = end.day() as i32; + let mut m2 = end.month() as i32; + let mut y2 = end.year(); + + if d1 == 31 || (m1 == 2 && (d1 == 29 || (d1 == 28 && !is_leap_year(y1)))) { + d1 = 30; + } + + if d2 == 31 { + if d1 != 30 { + d2 = 1; + if m2 == 12 { + y2 += 1; + m2 = 1; + } else { + m2 += 1; + } + } else { + d2 = 30; + } + } + + d2 + m2 * 30 + y2 * 360 - d1 - m1 * 30 - y1 * 360 +} + +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 = 30; + } + if d2 == 31 { + d2 = 30; + } + + d2 + m2 * 30 + y2 * 360 - d1 - m1 * 30 - y1 * 360 +} + +fn days_between(start: i64, end: i64, basis: i32) -> Result { + let start_date = from_excel_date(start)?; + let end_date = from_excel_date(end)?; + Ok(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()), + }) +} + +fn days_in_year(date: chrono::NaiveDate, basis: i32) -> Result { + Ok(match basis { + 0 | 2 | 4 => 360, + 1 => { + if is_leap_year(date.year()) { + 366 + } else { + 365 + } + } + 3 => 365, + _ => return Err("invalid basis".to_string()), + }) +} + +fn year_frac(start: i64, end: i64, basis: i32) -> Result { + let start_date = from_excel_date(start)?; + let days = days_between(start, end, basis)? as f64; + let year_days = days_in_year(start_date, basis)? as f64; + Ok(days / year_days) +} + +fn year_diff(start: i64, end: i64, basis: i32) -> Result { + year_frac(start, end, basis) +} + fn compute_payment( rate: f64, nper: f64, @@ -1652,7 +1740,6 @@ impl Model { CalcResult::Number(result) } - // PRICE(settlement, maturity, rate, yld, redemption, frequency, [basis]) pub(crate) fn fn_price(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if !(6..=7).contains(&args.len()) { return CalcResult::new_args_number_error(cell); @@ -1720,7 +1807,117 @@ impl Model { CalcResult::Number(price) } - // YIELD(settlement, maturity, rate, pr, redemption, frequency, [basis]) + pub(crate) fn fn_pricedisc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(4..=5).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + let 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 discount = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let redemption = match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let basis = if arg_count == 5 { + match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + } + } else { + 0.0 + }; + if settlement >= maturity || discount <= 0.0 || redemption <= 0.0 { + 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 + { + return CalcResult::new_error(Error::NUM, cell, "Invalid number for date".to_string()); + } + let yd = match year_diff(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 result = redemption * (1.0 - discount * yd); + CalcResult::Number(result) + } + + 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 f_iss_mat = 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 f_iss_set = 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 f_set_mat = 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 + f_iss_mat * rate; + result /= 1.0 + f_set_mat * yld; + result -= f_iss_set * rate; + result *= 100.0; + CalcResult::Number(result) + } + pub(crate) fn fn_yield(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if !(6..=7).contains(&args.len()) { return CalcResult::new_args_number_error(cell); @@ -1786,6 +1983,261 @@ impl Model { } } + pub(crate) fn fn_yielddisc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(4..=5).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + let 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 pr = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let redemption = match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let basis = if arg_count == 5 { + match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + } + } else { + 0.0 + }; + if settlement >= maturity || pr <= 0.0 || redemption <= 0.0 { + 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 + { + return CalcResult::new_error(Error::NUM, cell, "Invalid number for date".to_string()); + } + let yf = 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 result = (redemption / pr - 1.0) / yf; + 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 f_iss_mat = 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 f_iss_set = 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 f_set_mat = 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 + f_iss_mat * rate; + y /= price / 100.0 + f_iss_set * rate; + y -= 1.0; + y /= f_set_mat; + CalcResult::Number(y) + } + + // DISC(settlement, maturity, pr, redemption, [basis]) + pub(crate) fn fn_disc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(4..=5).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + let 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 pr = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let redemption = match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let basis = if arg_count == 5 { + match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + } + } else { + 0.0 + }; + if pr <= 0.0 || redemption <= 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 + { + return CalcResult::new_error(Error::NUM, cell, "Invalid number for date".to_string()); + } + let yf = 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 result = (1.0 - pr / redemption) / yf; + CalcResult::Number(result) + } + + // RECEIVED(settlement, maturity, investment, discount, [basis]) + pub(crate) fn fn_received(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(4..=5).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + let 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 investment = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let discount = match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let basis = if arg_count == 5 { + match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + } + } else { + 0.0 + }; + if investment <= 0.0 || discount <= 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 + { + return CalcResult::new_error(Error::NUM, cell, "Invalid number for date".to_string()); + } + let yd = match year_diff(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 result = investment / (1.0 - discount * yd); + CalcResult::Number(result) + } + + // INTRATE(settlement, maturity, investment, redemption, [basis]) + pub(crate) fn fn_intrate(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(4..=5).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + let 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 investment = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let redemption = match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let basis = if arg_count == 5 { + match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + } + } else { + 0.0 + }; + if investment <= 0.0 || redemption <= 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 + { + return CalcResult::new_error(Error::NUM, cell, "Invalid number for date".to_string()); + } + let yd = match year_diff(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 result = ((redemption / investment) - 1.0) / yd; + CalcResult::Number(result) + } + // 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 7371d46..536acec 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -247,6 +247,13 @@ pub enum Function { Tbilleq, Tbillprice, Tbillyield, + Pricedisc, + Pricemat, + Yielddisc, + Yieldmat, + Disc, + Received, + Intrate, Xirr, Xnpv, Yield, @@ -318,7 +325,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -511,6 +518,13 @@ impl Function { Function::Tbillyield, Function::Tbillprice, Function::Tbilleq, + Function::Pricedisc, + Function::Pricemat, + Function::Yielddisc, + Function::Yieldmat, + Function::Disc, + Function::Received, + Function::Intrate, Function::Dollarde, Function::Dollarfr, Function::Ddb, @@ -852,6 +866,13 @@ impl Function { "TBILLYIELD" => Some(Function::Tbillyield), "TBILLPRICE" => Some(Function::Tbillprice), "TBILLEQ" => Some(Function::Tbilleq), + "PRICEDISC" => Some(Function::Pricedisc), + "PRICEMAT" => Some(Function::Pricemat), + "YIELDDISC" => Some(Function::Yielddisc), + "YIELDMAT" => Some(Function::Yieldmat), + "DISC" => Some(Function::Disc), + "RECEIVED" => Some(Function::Received), + "INTRATE" => Some(Function::Intrate), "DOLLARDE" => Some(Function::Dollarde), "DOLLARFR" => Some(Function::Dollarfr), @@ -1099,6 +1120,13 @@ impl fmt::Display for Function { Function::Tbillyield => write!(f, "TBILLYIELD"), Function::Tbillprice => write!(f, "TBILLPRICE"), Function::Tbilleq => write!(f, "TBILLEQ"), + Function::Pricedisc => write!(f, "PRICEDISC"), + Function::Pricemat => write!(f, "PRICEMAT"), + Function::Yielddisc => write!(f, "YIELDDISC"), + Function::Yieldmat => write!(f, "YIELDMAT"), + Function::Disc => write!(f, "DISC"), + Function::Received => write!(f, "RECEIVED"), + Function::Intrate => write!(f, "INTRATE"), Function::Dollarde => write!(f, "DOLLARDE"), Function::Dollarfr => write!(f, "DOLLARFR"), Function::Ddb => write!(f, "DDB"), @@ -1381,6 +1409,13 @@ impl Model { Function::Tbillyield => self.fn_tbillyield(args, cell), Function::Tbillprice => self.fn_tbillprice(args, cell), Function::Tbilleq => self.fn_tbilleq(args, cell), + Function::Pricedisc => self.fn_pricedisc(args, cell), + Function::Pricemat => self.fn_pricemat(args, cell), + Function::Yielddisc => self.fn_yielddisc(args, cell), + Function::Yieldmat => self.fn_yieldmat(args, cell), + Function::Disc => self.fn_disc(args, cell), + Function::Received => self.fn_received(args, cell), + Function::Intrate => self.fn_intrate(args, cell), Function::Dollarde => self.fn_dollarde(args, cell), Function::Dollarfr => self.fn_dollarfr(args, cell), Function::Ddb => self.fn_ddb(args, cell), diff --git a/base/src/test/test_fn_financial_bonds.rs b/base/src/test/test_fn_financial_bonds.rs index 0e36647..54f42b0 100644 --- a/base/src/test/test_fn_financial_bonds.rs +++ b/base/src/test/test_fn_financial_bonds.rs @@ -1,6 +1,6 @@ #![allow(clippy::unwrap_used)] -use crate::test::util::new_empty_model; +use crate::{cell::CellValue, test::util::new_empty_model}; #[test] fn fn_price_yield() { @@ -108,6 +108,226 @@ fn fn_price_invalid_frequency() { assert_eq!(model._get_text("B3"), *"#NUM!"); } +#[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_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_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_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_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_bond_functions_arguments() { + let mut model = new_empty_model(); + + // PRICEDISC: 4-5 args + model._set("A1", "=PRICEDISC()"); + model._set("A2", "=PRICEDISC(1,2,3)"); + model._set("A3", "=PRICEDISC(1,2,3,4,5,6)"); + + // PRICEMAT: 5-6 args + model._set("B1", "=PRICEMAT()"); + model._set("B2", "=PRICEMAT(1,2,3,4)"); + model._set("B3", "=PRICEMAT(1,2,3,4,5,6,7)"); + + // YIELDDISC: 4-5 args + model._set("C1", "=YIELDDISC()"); + model._set("C2", "=YIELDDISC(1,2,3)"); + model._set("C3", "=YIELDDISC(1,2,3,4,5,6)"); + + // YIELDMAT: 5-6 args + model._set("D1", "=YIELDMAT()"); + model._set("D2", "=YIELDMAT(1,2,3,4)"); + model._set("D3", "=YIELDMAT(1,2,3,4,5,6,7)"); + + // DISC: 4-5 args + model._set("E1", "=DISC()"); + model._set("E2", "=DISC(1,2,3)"); + model._set("E3", "=DISC(1,2,3,4,5,6)"); + + // RECEIVED: 4-5 args + model._set("F1", "=RECEIVED()"); + model._set("F2", "=RECEIVED(1,2,3)"); + model._set("F3", "=RECEIVED(1,2,3,4,5,6)"); + + // INTRATE: 4-5 args + model._set("G1", "=INTRATE()"); + model._set("G2", "=INTRATE(1,2,3)"); + model._set("G3", "=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!"); + + assert_eq!(model._get_text("F1"), *"#ERROR!"); + assert_eq!(model._get_text("F2"), *"#ERROR!"); + assert_eq!(model._get_text("F3"), *"#ERROR!"); + + assert_eq!(model._get_text("G1"), *"#ERROR!"); + assert_eq!(model._get_text("G2"), *"#ERROR!"); + assert_eq!(model._get_text("G3"), *"#ERROR!"); +} + +#[test] +fn fn_bond_functions_date_boundaries() { + let mut model = new_empty_model(); + + // Date boundary values + model._set("A1", "0"); // Below MINIMUM_DATE_SERIAL_NUMBER + model._set("A2", "1"); // MINIMUM_DATE_SERIAL_NUMBER + model._set("A3", "2958465"); // MAXIMUM_DATE_SERIAL_NUMBER + model._set("A4", "2958466"); // Above MAXIMUM_DATE_SERIAL_NUMBER + + // Test settlement < minimum + model._set("B1", "=PRICEDISC(A1,A2,0.05,100)"); + model._set("B2", "=YIELDDISC(A1,A2,95,100)"); + model._set("B3", "=DISC(A1,A2,95,100)"); + model._set("B4", "=RECEIVED(A1,A2,1000,0.05)"); + model._set("B5", "=INTRATE(A1,A2,1000,1050)"); + + // Test maturity > maximum + model._set("C1", "=PRICEDISC(A2,A4,0.05,100)"); + model._set("C2", "=YIELDDISC(A2,A4,95,100)"); + model._set("C3", "=DISC(A2,A4,95,100)"); + model._set("C4", "=RECEIVED(A2,A4,1000,0.05)"); + model._set("C5", "=INTRATE(A2,A4,1000,1050)"); + + // Test PRICEMAT/YIELDMAT with issue < minimum + model._set("D1", "=PRICEMAT(A2,A3,A1,0.06,0.05)"); + model._set("D2", "=YIELDMAT(A2,A3,A1,0.06,99)"); + + // Test PRICEMAT/YIELDMAT with issue > maximum + model._set("E1", "=PRICEMAT(A2,A3,A4,0.06,0.05)"); + model._set("E2", "=YIELDMAT(A2,A3,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("B3"), *"#NUM!"); +} + #[test] fn fn_yield_invalid_frequency() { let mut model = new_empty_model(); @@ -125,6 +345,35 @@ fn fn_yield_invalid_frequency() { assert_eq!(model._get_text("B3"), *"#NUM!"); } +#[test] +fn fn_bond_functions_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 + + // Test settlement >= maturity + model._set("B1", "=PRICEDISC(A1,A2,0.05,100)"); + model._set("B2", "=YIELDDISC(A1,A2,95,100)"); + model._set("B3", "=DISC(A1,A2,95,100)"); + model._set("B4", "=RECEIVED(A1,A2,1000,0.05)"); + model._set("B5", "=INTRATE(A1,A2,1000,1050)"); + model._set("B6", "=PRICEMAT(A1,A2,A3,0.06,0.05)"); + model._set("B7", "=YIELDMAT(A1,A2,A3,0.06,99)"); + + // Test settlement < issue for YIELDMAT/PRICEMAT + model._set("A4", "=DATE(2023,1,1)"); // later issue date + model._set("C1", "=PRICEMAT(A1,A2,A4,0.06,0.05)"); // settlement < issue + model._set("C2", "=YIELDMAT(A1,A2,A4,0.06,99)"); // settlement < issue + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("B3"), *"#NUM!"); +} + #[test] fn fn_price_invalid_dates() { let mut model = new_empty_model(); @@ -140,6 +389,47 @@ fn fn_price_invalid_dates() { assert_eq!(model._get_text("B2"), *"#NUM!"); } +#[test] +fn fn_bond_functions_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)"); + + // Test negative/zero prices and redemptions + model._set("B1", "=PRICEDISC(A1,A2,0.05,0)"); // zero redemption + model._set("B2", "=PRICEDISC(A1,A2,0,100)"); // zero discount + model._set("B3", "=PRICEDISC(A1,A2,-0.05,100)"); // negative discount + + model._set("C1", "=YIELDDISC(A1,A2,0,100)"); // zero price + model._set("C2", "=YIELDDISC(A1,A2,95,0)"); // zero redemption + model._set("C3", "=YIELDDISC(A1,A2,-95,100)"); // negative price + + model._set("D1", "=DISC(A1,A2,0,100)"); // zero price + model._set("D2", "=DISC(A1,A2,95,0)"); // zero redemption + model._set("D3", "=DISC(A1,A2,-95,100)"); // negative price + + model._set("E1", "=RECEIVED(A1,A2,0,0.05)"); // zero investment + model._set("E2", "=RECEIVED(A1,A2,1000,0)"); // zero discount + model._set("E3", "=RECEIVED(A1,A2,-1000,0.05)"); // negative investment + + model._set("F1", "=INTRATE(A1,A2,0,1050)"); // zero investment + model._set("F2", "=INTRATE(A1,A2,1000,0)"); // zero redemption + model._set("F3", "=INTRATE(A1,A2,-1000,1050)"); // negative investment + + model._set("G1", "=PRICEMAT(A1,A2,A3,-0.06,0.05)"); // negative rate + model._set("G2", "=PRICEMAT(A1,A2,A3,0.06,-0.05)"); // negative yield + + model._set("H1", "=YIELDMAT(A1,A2,A3,0.06,0)"); // zero price + model._set("H2", "=YIELDMAT(A1,A2,A3,-0.06,99)"); // negative rate + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); +} + #[test] fn fn_yield_invalid_dates() { let mut model = new_empty_model(); @@ -237,3 +527,89 @@ fn fn_price_yield_round_trip_stability() { "Round-trip should be stable: {price1} vs {price2}" ); } + +#[test] +fn fn_bond_functions_basis_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)"); + + // Test valid basis values (0-4) + 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)"); + + // Test invalid basis values + 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._set("C7", "=PRICEMAT(A1,A2,A3,0.06,0.05,7)"); + model._set("C8", "=YIELDMAT(A1,A2,A3,0.06,99,-3)"); + + model.evaluate(); + + // Valid basis should work + 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!"); + + // Invalid basis should 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!"); + assert_eq!(model._get_text("C7"), *"#NUM!"); + assert_eq!(model._get_text("C8"), *"#NUM!"); +} + +#[test] +fn fn_bond_functions_relationships() { + // Test mathematical relationships between functions + let mut model = new_empty_model(); + model._set("A1", "=DATE(2021,1,1)"); + model._set("A2", "=DATE(2021,7,1)"); + + model._set("B1", "=PRICEDISC(A1,A2,5%,100)"); + model._set("B2", "=YIELDDISC(A1,A2,B1,100)"); + model._set("B3", "=DISC(A1,A2,B1,100)"); + model._set("B4", "=RECEIVED(A1,A2,1000,5%)"); + model._set("B5", "=INTRATE(A1,A2,1000,1050)"); + model._set("B6", "=PRICEMAT(A1,A2,DATE(2020,7,1),6%,5%)"); + model._set("B7", "=YIELDMAT(A1,A2,DATE(2020,7,1),6%,99)"); + + model.evaluate(); + + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B1"), + Ok(CellValue::Number(97.5)) + ); + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B2") { + assert!((v - 0.051282051).abs() < 1e-6); + } + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B3") { + assert!((v - 0.05).abs() < 1e-6); + } + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B4") { + assert!((v - 1025.641025).abs() < 1e-6); + } + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B5") { + assert!((v - 0.10).abs() < 1e-6); + } + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B6") { + assert!((v - 100.414634).abs() < 1e-6); + } + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B7") { + assert!((v - 0.078431372).abs() < 1e-6); + } +} diff --git a/docs/src/functions/financial.md b/docs/src/functions/financial.md index 44ae7ef..909abd7 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 | | – | | DOLLARDE | | – | | DOLLARFR | | – | | DURATION | | – | | EFFECT | | – | | FV | | [FV](financial/fv) | | FVSCHEDULE | | [FVSCHEDULE](financial/fvschedule) | -| INTRATE | | – | +| 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 | | – | -| PRICEMAT | | – | +| PRICEDISC | | – | +| PRICEMAT | | – | | PV | | [PV](financial/pv) | | RATE | | – | -| RECEIVED | | – | +| RECEIVED | | – | | RRI | | - | | SLN | | – | | SYD | | – | @@ -66,3 +66,6 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | YIELD | | – | | YIELDDISC | | – | | YIELDMAT | | – | +| YIELD | | – | +| YIELDDISC | | – | +| YIELDMAT | | – | diff --git a/docs/src/functions/financial/disc.md b/docs/src/functions/financial/disc.md index ace252e..779b882 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) +🚧 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/intrate.md b/docs/src/functions/financial/intrate.md index 4e393f3..f26227b 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) +🚧 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/pricedisc.md b/docs/src/functions/financial/pricedisc.md index 9955d05..7a639bf 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) +🚧 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/pricemat.md b/docs/src/functions/financial/pricemat.md index 9cc61c9..360f1b2 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) +🚧 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/received.md b/docs/src/functions/financial/received.md index 4dd1e7d..fb8ca17 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) +🚧 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/yielddisc.md b/docs/src/functions/financial/yielddisc.md index 547f1c2..f6720bf 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) +🚧 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/yieldmat.md b/docs/src/functions/financial/yieldmat.md index 483fef3..530631b 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) +🚧 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