diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 6784981..290d8e0 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -764,6 +764,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 3, 0), Function::Pmt => args_signature_scalars(arg_count, 3, 2), Function::Ppmt => args_signature_scalars(arg_count, 4, 2), + Function::Price => args_signature_scalars(arg_count, 6, 1), Function::Pv => args_signature_scalars(arg_count, 3, 2), Function::Rate => args_signature_scalars(arg_count, 3, 3), Function::Rri => args_signature_scalars(arg_count, 3, 0), @@ -772,6 +773,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 3, 0), Function::Tbillprice => 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::Xirr => args_signature_xirr(arg_count), Function::Xnpv => args_signature_xnpv(arg_count), Function::Besseli => args_signature_scalars(arg_count, 2, 0), @@ -1030,6 +1032,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Pduration => not_implemented(args), Function::Pmt => not_implemented(args), Function::Ppmt => not_implemented(args), + Function::Price => not_implemented(args), Function::Pv => not_implemented(args), Function::Rate => not_implemented(args), Function::Rri => not_implemented(args), @@ -1038,6 +1041,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Tbilleq => not_implemented(args), Function::Tbillprice => not_implemented(args), Function::Tbillyield => not_implemented(args), + Function::Yield => 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 9f588ae..c1de2e0 100644 --- a/base/src/functions/financial.rs +++ b/base/src/functions/financial.rs @@ -1623,6 +1623,140 @@ 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); + } + 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 rate = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let yld = match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let redemption = match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let frequency = match self.get_number_no_bools(&args[5], cell) { + Ok(f) => f.round() as i32, + Err(s) => return s, + }; + if frequency != 1 && frequency != 2 && frequency != 4 { + return CalcResult::new_error( + Error::NUM, + cell, + "frequency should be 1, 2 or 4".to_string(), + ); + } + if settlement >= maturity { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + ); + } + if args.len() == 7 { + let _basis = match self.get_number_no_bools(&args[6], cell) { + Ok(f) => f, + Err(s) => return s, + }; + // basis is currently ignored + } + let days = maturity - settlement; + let periods = ((days * frequency as f64) / 365.0).round(); + if periods <= 0.0 { + return CalcResult::new_error(Error::NUM, cell, "invalid dates".to_string()); + } + let coupon = redemption * rate / frequency as f64; + let r = yld / frequency as f64; + let mut price = 0.0; + for i in 1..=(periods as i32) { + price += coupon / (1.0 + r).powf(i as f64); + } + price += redemption / (1.0 + r).powf(periods); + if price.is_nan() || price.is_infinite() { + return CalcResult::new_error(Error::NUM, cell, "Invalid data".to_string()); + } + CalcResult::Number(price) + } + + // YIELD(settlement, maturity, rate, pr, redemption, frequency, [basis]) + 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); + } + 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 rate = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let price = match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let redemption = match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let frequency = match self.get_number_no_bools(&args[5], cell) { + Ok(f) => f.round() as i32, + Err(s) => return s, + }; + if frequency != 1 && frequency != 2 && frequency != 4 { + return CalcResult::new_error( + Error::NUM, + cell, + "frequency should be 1, 2 or 4".to_string(), + ); + } + if settlement >= maturity { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + ); + } + if args.len() == 7 { + let _basis = match self.get_number_no_bools(&args[6], cell) { + Ok(f) => f, + Err(s) => return s, + }; + // basis ignored + } + let days = maturity - settlement; + let periods = ((days * frequency as f64) / 365.0).round(); + if periods <= 0.0 { + return CalcResult::new_error(Error::NUM, cell, "invalid dates".to_string()); + } + let coupon = redemption * rate / frequency as f64; + match compute_rate(-price, redemption, periods, coupon, 0, 0.1) { + Ok(r) => CalcResult::Number(r * frequency as f64), + Err(err) => CalcResult::Error { + error: err.0, + origin: cell, + message: err.1, + }, + } + } + // 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 a59937e..5d2e08b 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -237,6 +237,7 @@ pub enum Function { Pduration, Pmt, Ppmt, + Price, Pv, Rate, Rri, @@ -247,6 +248,7 @@ pub enum Function { Tbillyield, Xirr, Xnpv, + Yield, // Engineering: Bessel and transcendental functions Besseli, @@ -315,7 +317,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -482,12 +484,14 @@ impl Function { Function::Nper, Function::Fv, Function::Ppmt, + Function::Price, Function::Ipmt, Function::Npv, Function::Mirr, Function::Irr, Function::Xirr, Function::Xnpv, + Function::Yield, Function::Rept, Function::Textafter, Function::Textbefore, @@ -823,9 +827,11 @@ impl Function { "NPER" => Some(Function::Nper), "FV" => Some(Function::Fv), "PPMT" => Some(Function::Ppmt), + "PRICE" => Some(Function::Price), "IPMT" => Some(Function::Ipmt), "NPV" => Some(Function::Npv), "XNPV" => Some(Function::Xnpv), + "YIELD" => Some(Function::Yield), "MIRR" => Some(Function::Mirr), "IRR" => Some(Function::Irr), "XIRR" => Some(Function::Xirr), @@ -1064,12 +1070,14 @@ impl fmt::Display for Function { Function::Nper => write!(f, "NPER"), Function::Fv => write!(f, "FV"), Function::Ppmt => write!(f, "PPMT"), + Function::Price => write!(f, "PRICE"), Function::Ipmt => write!(f, "IPMT"), Function::Npv => write!(f, "NPV"), Function::Mirr => write!(f, "MIRR"), Function::Irr => write!(f, "IRR"), Function::Xirr => write!(f, "XIRR"), Function::Xnpv => write!(f, "XNPV"), + Function::Yield => write!(f, "YIELD"), Function::Rept => write!(f, "REPT"), Function::Textafter => write!(f, "TEXTAFTER"), Function::Textbefore => write!(f, "TEXTBEFORE"), @@ -1343,12 +1351,14 @@ impl Model { Function::Nper => self.fn_nper(args, cell), Function::Fv => self.fn_fv(args, cell), Function::Ppmt => self.fn_ppmt(args, cell), + Function::Price => self.fn_price(args, cell), Function::Ipmt => self.fn_ipmt(args, cell), Function::Npv => self.fn_npv(args, cell), Function::Mirr => self.fn_mirr(args, cell), Function::Irr => self.fn_irr(args, cell), Function::Xirr => self.fn_xirr(args, cell), Function::Xnpv => self.fn_xnpv(args, cell), + Function::Yield => self.fn_yield(args, cell), Function::Rept => self.fn_rept(args, cell), Function::Textafter => self.fn_textafter(args, cell), Function::Textbefore => self.fn_textbefore(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 55cb702..c06077d 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -20,6 +20,7 @@ mod test_fn_day; mod test_fn_duration; mod test_fn_exact; mod test_fn_financial; +mod test_fn_financial_bonds; mod test_fn_formulatext; mod test_fn_if; mod test_fn_maxifs; diff --git a/base/src/test/test_fn_financial_bonds.rs b/base/src/test/test_fn_financial_bonds.rs new file mode 100644 index 0000000..0e36647 --- /dev/null +++ b/base/src/test/test_fn_financial_bonds.rs @@ -0,0 +1,239 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn fn_price_yield() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + model._set("A3", "5%"); + + model._set("B1", "=PRICE(A1,A2,A3,6%,100,1)"); + model._set("B2", "=YIELD(A1,A2,A3,B1,100,1)"); + + model.evaluate(); + assert_eq!(model._get_text("B1"), "99.056603774"); + assert_eq!(model._get_text("B2"), "0.06"); +} + +#[test] +fn fn_price_frequencies() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=PRICE(A1,A2,5%,6%,100,1)"); + model._set("B2", "=PRICE(A1,A2,5%,6%,100,2)"); + model._set("B3", "=PRICE(A1,A2,5%,6%,100,4)"); + + model.evaluate(); + + let annual: f64 = model._get_text("B1").parse().unwrap(); + let semi: f64 = model._get_text("B2").parse().unwrap(); + let quarterly: f64 = model._get_text("B3").parse().unwrap(); + + assert_ne!(annual, semi); + assert_ne!(semi, quarterly); +} + +#[test] +fn fn_yield_frequencies() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=YIELD(A1,A2,5%,99,100,1)"); + model._set("B2", "=YIELD(A1,A2,5%,99,100,2)"); + model._set("B3", "=YIELD(A1,A2,5%,99,100,4)"); + + model.evaluate(); + + let annual: f64 = model._get_text("B1").parse().unwrap(); + let semi: f64 = model._get_text("B2").parse().unwrap(); + let quarterly: f64 = model._get_text("B3").parse().unwrap(); + + assert_ne!(annual, semi); + assert_ne!(semi, quarterly); +} + +#[test] +fn fn_price_argument_errors() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=PRICE()"); + model._set("B2", "=PRICE(A1,A2,5%,6%,100)"); + model._set("B3", "=PRICE(A1,A2,5%,6%,100,2,0,99)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); + assert_eq!(model._get_text("B3"), *"#ERROR!"); +} + +#[test] +fn fn_yield_argument_errors() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=YIELD()"); + model._set("B2", "=YIELD(A1,A2,5%,99,100)"); + model._set("B3", "=YIELD(A1,A2,5%,99,100,2,0,99)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); + assert_eq!(model._get_text("B3"), *"#ERROR!"); +} + +#[test] +fn fn_price_invalid_frequency() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=PRICE(A1,A2,5%,6%,100,0)"); + model._set("B2", "=PRICE(A1,A2,5%,6%,100,3)"); + model._set("B3", "=PRICE(A1,A2,5%,6%,100,5)"); + + 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(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=YIELD(A1,A2,5%,99,100,0)"); + model._set("B2", "=YIELD(A1,A2,5%,99,100,3)"); + model._set("B3", "=YIELD(A1,A2,5%,99,100,5)"); + + 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(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=PRICE(A2,A1,5%,6%,100,2)"); + model._set("B2", "=PRICE(A1,A1,5%,6%,100,2)"); + + 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(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=YIELD(A2,A1,5%,99,100,2)"); + model._set("B2", "=YIELD(A1,A1,5%,99,100,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); +} + +#[test] +fn fn_price_with_basis() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=PRICE(A1,A2,5%,6%,100,2,0)"); + model._set("B2", "=PRICE(A1,A2,5%,6%,100,2,1)"); + + model.evaluate(); + + assert!(model._get_text("B1").parse::().is_ok()); + assert!(model._get_text("B2").parse::().is_ok()); +} + +#[test] +fn fn_yield_with_basis() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=YIELD(A1,A2,5%,99,100,2,0)"); + model._set("B2", "=YIELD(A1,A2,5%,99,100,2,1)"); + + model.evaluate(); + + assert!(model._get_text("B1").parse::().is_ok()); + assert!(model._get_text("B2").parse::().is_ok()); +} + +#[test] +fn fn_price_yield_inverse_functions() { + // Verifies PRICE and YIELD are mathematical inverses + // Regression test for periods calculation type mismatch + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2023,1,15)"); + model._set("A2", "=DATE(2024,7,15)"); // ~1.5 years, fractional periods + model._set("A3", "4.75%"); // coupon + model._set("A4", "5.125%"); // yield + + model._set("B1", "=PRICE(A1,A2,A3,A4,100,2)"); + model._set("B2", "=YIELD(A1,A2,A3,B1,100,2)"); + + model.evaluate(); + + let calculated_yield: f64 = model._get_text("B2").parse().unwrap(); + let expected_yield = 0.05125; + + assert!( + (calculated_yield - expected_yield).abs() < 1e-12, + "YIELD should recover original yield: expected {expected_yield}, got {calculated_yield}" + ); +} + +#[test] +fn fn_price_yield_round_trip_stability() { + // Tests numerical stability through multiple PRICE->YIELD->PRICE cycles + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2023,3,10)"); + model._set("A2", "=DATE(2024,11,22)"); // Irregular period length + model._set("A3", "3.25%"); // coupon rate + model._set("A4", "4.875%"); // initial yield + + // First round-trip + model._set("B1", "=PRICE(A1,A2,A3,A4,100,4)"); + model._set("B2", "=YIELD(A1,A2,A3,B1,100,4)"); + + // Second round-trip + model._set("B3", "=PRICE(A1,A2,A3,B2,100,4)"); + + model.evaluate(); + + let price1: f64 = model._get_text("B1").parse().unwrap(); + let price2: f64 = model._get_text("B3").parse().unwrap(); + + assert!( + (price1 - price2).abs() < 1e-10, + "Round-trip should be stable: {price1} vs {price2}" + ); +} diff --git a/docs/src/functions/financial.md b/docs/src/functions/financial.md index b666396..606e01b 100644 --- a/docs/src/functions/financial.md +++ b/docs/src/functions/financial.md @@ -48,7 +48,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | PDURATION | | – | | PMT | | – | | PPMT | | – | -| PRICE | | – | +| PRICE | | – | | PRICEDISC | | – | | PRICEMAT | | – | | PV | | [PV](financial/pv) | @@ -63,6 +63,6 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | VDB | | – | | XIRR | | – | | XNPV | | – | -| YIELD | | – | +| YIELD | | – | | YIELDDISC | | – | | YIELDMAT | | – | diff --git a/docs/src/functions/financial/price.md b/docs/src/functions/financial/price.md index 378de6d..a3178ed 100644 --- a/docs/src/functions/financial/price.md +++ b/docs/src/functions/financial/price.md @@ -7,6 +7,5 @@ lang: en-US # PRICE ::: 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/yield.md b/docs/src/functions/financial/yield.md index 7138dc7..0bd9e9a 100644 --- a/docs/src/functions/financial/yield.md +++ b/docs/src/functions/financial/yield.md @@ -7,6 +7,5 @@ lang: en-US # YIELD ::: 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