From 04e012b518e7fc42ad9232fe436e842b45473d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Thu, 6 Nov 2025 21:44:56 +0100 Subject: [PATCH] merge fvschedule #56 # Conflicts: # base/src/functions/mod.rs # base/src/test/mod.rs --- .../src/expressions/parser/static_analysis.rs | 10 ++ base/src/functions/financial.rs | 29 ++++ base/src/functions/mod.rs | 7 +- base/src/test/mod.rs | 1 + base/src/test/test_fn_financial.rs | 23 ++++ base/src/test/test_fn_fvschedule.rs | 127 ++++++++++++++++++ docs/src/functions/financial.md | 2 +- docs/src/functions/financial/fvschedule.md | 3 +- 8 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 base/src/test/test_fn_fvschedule.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 290d8e0..2f04dc0 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -550,6 +550,14 @@ fn args_signature_irr(arg_count: usize) -> Vec { } } +fn args_signature_fvschedule(arg_count: usize) -> Vec { + if arg_count == 2 { + vec![Signature::Scalar, Signature::Vector] + } else { + vec![Signature::Error; arg_count] + } +} + fn args_signature_xirr(arg_count: usize) -> Vec { if arg_count == 2 { vec![Signature::Vector; arg_count] @@ -752,6 +760,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 2, 0), Function::Effect => args_signature_scalars(arg_count, 2, 0), Function::Fv => args_signature_scalars(arg_count, 3, 2), + Function::Fvschedule => args_signature_fvschedule(arg_count), Function::Ipmt => args_signature_scalars(arg_count, 4, 2), Function::Irr => args_signature_irr(arg_count), Function::Ispmt => args_signature_scalars(arg_count, 4, 0), @@ -1020,6 +1029,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Dollarfr => not_implemented(args), Function::Effect => not_implemented(args), Function::Fv => not_implemented(args), + Function::Fvschedule => not_implemented(args), Function::Ipmt => not_implemented(args), Function::Irr => not_implemented(args), Function::Ispmt => not_implemented(args), diff --git a/base/src/functions/financial.rs b/base/src/functions/financial.rs index c1de2e0..037ae63 100644 --- a/base/src/functions/financial.rs +++ b/base/src/functions/financial.rs @@ -641,6 +641,35 @@ impl Model { } } + // FVSCHEDULE(principal, schedule) + pub(crate) fn fn_fvschedule(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let principal = match self.get_number(&args[0], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let schedule = match self.get_array_of_numbers(&args[1], &cell) { + Ok(s) => s, + Err(err) => return err, + }; + let mut result = principal; + for rate in schedule { + if rate <= -1.0 { + return CalcResult::new_error(Error::NUM, cell, "Rate must be > -1".to_string()); + } + result *= 1.0 + rate; + } + if result.is_infinite() { + return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string()); + } + if result.is_nan() { + return CalcResult::new_error(Error::NUM, cell, "Invalid result".to_string()); + } + CalcResult::Number(result) + } + // IPMT(rate, per, nper, pv, [fv], [type]) pub(crate) fn fn_ipmt(&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 5d2e08b..7371d46 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -225,6 +225,7 @@ pub enum Function { Dollarfr, Effect, Fv, + Fvschedule, Ipmt, Irr, Ispmt, @@ -317,7 +318,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -483,6 +484,7 @@ impl Function { Function::Rate, Function::Nper, Function::Fv, + Function::Fvschedule, Function::Ppmt, Function::Price, Function::Ipmt, @@ -826,6 +828,7 @@ impl Function { "RATE" => Some(Function::Rate), "NPER" => Some(Function::Nper), "FV" => Some(Function::Fv), + "FVSCHEDULE" => Some(Function::Fvschedule), "PPMT" => Some(Function::Ppmt), "PRICE" => Some(Function::Price), "IPMT" => Some(Function::Ipmt), @@ -1069,6 +1072,7 @@ impl fmt::Display for Function { Function::Rate => write!(f, "RATE"), Function::Nper => write!(f, "NPER"), Function::Fv => write!(f, "FV"), + Function::Fvschedule => write!(f, "FVSCHEDULE"), Function::Ppmt => write!(f, "PPMT"), Function::Price => write!(f, "PRICE"), Function::Ipmt => write!(f, "IPMT"), @@ -1350,6 +1354,7 @@ impl Model { Function::Rate => self.fn_rate(args, cell), Function::Nper => self.fn_nper(args, cell), Function::Fv => self.fn_fv(args, cell), + Function::Fvschedule => self.fn_fvschedule(args, cell), Function::Ppmt => self.fn_ppmt(args, cell), Function::Price => self.fn_price(args, cell), Function::Ipmt => self.fn_ipmt(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index c06077d..21de93f 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -65,6 +65,7 @@ mod test_escape_quotes; mod test_extend; mod test_fn_fv; mod test_fn_round; +mod test_fn_fvschedule; mod test_fn_type; mod test_frozen_rows_and_columns; mod test_geomean; diff --git a/base/src/test/test_fn_financial.rs b/base/src/test/test_fn_financial.rs index fb8c3a2..4120997 100644 --- a/base/src/test/test_fn_financial.rs +++ b/base/src/test/test_fn_financial.rs @@ -26,6 +26,10 @@ fn fn_arguments() { model._set("E2", "=RATE(1,1)"); model._set("E3", "=RATE(1,1,1,1,1,1)"); + model._set("F1", "=FVSCHEDULE()"); + model._set("F2", "=FVSCHEDULE(1)"); + model._set("F3", "=FVSCHEDULE(1,1,1)"); + model.evaluate(); assert_eq!(model._get_text("A1"), *"#ERROR!"); @@ -47,6 +51,10 @@ fn fn_arguments() { 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!"); } #[test] @@ -469,3 +477,18 @@ fn fn_db_misc() { assert_eq!(model._get_text("B1"), "$0.00"); } + +#[test] +fn fn_fvschedule() { + let mut model = new_empty_model(); + model._set("A1", "1000"); + model._set("A2", "0.08"); + model._set("A3", "0.09"); + model._set("A4", "0.1"); + + model._set("B1", "=FVSCHEDULE(A1, A2:A4)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "1294.92"); +} diff --git a/base/src/test/test_fn_fvschedule.rs b/base/src/test/test_fn_fvschedule.rs new file mode 100644 index 0000000..b2e7467 --- /dev/null +++ b/base/src/test/test_fn_fvschedule.rs @@ -0,0 +1,127 @@ +#![allow(clippy::unwrap_used)] + +use crate::{cell::CellValue, test::util::new_empty_model}; + +#[test] +fn computation() { + let mut model = new_empty_model(); + model._set("B1", "0.1"); + model._set("B2", "0.2"); + model._set("A1", "=FVSCHEDULE(100,B1:B2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "132"); +} + +#[test] +fn fvschedule_basic_with_precise_assertion() { + let mut model = new_empty_model(); + model._set("A1", "1000"); + model._set("B1", "0.09"); + model._set("B2", "0.11"); + model._set("B3", "0.1"); + + model._set("C1", "=FVSCHEDULE(A1,B1:B3)"); + model.evaluate(); + + assert_eq!( + model.get_cell_value_by_ref("Sheet1!C1"), + Ok(CellValue::Number(1330.89)) + ); +} + +#[test] +fn fvschedule_compound_rates() { + let mut model = new_empty_model(); + model._set("A1", "1"); + model._set("A2", "0.1"); + model._set("A3", "0.2"); + model._set("A4", "0.3"); + + model._set("B1", "=FVSCHEDULE(A1, A2:A4)"); + + model.evaluate(); + + // 1 * (1+0.1) * (1+0.2) * (1+0.3) = 1 * 1.1 * 1.2 * 1.3 = 1.716 + assert_eq!(model._get_text("B1"), "1.716"); +} + +#[test] +fn fvschedule_ignore_non_numbers() { + let mut model = new_empty_model(); + model._set("A1", "1"); + model._set("A2", "0.1"); + model._set("A3", "foo"); // non-numeric value should be ignored + model._set("A4", "0.2"); + + model._set("B1", "=FVSCHEDULE(A1, A2:A4)"); + + model.evaluate(); + + // 1 * (1+0.1) * (1+0.2) = 1 * 1.1 * 1.2 = 1.32 + assert_eq!(model._get_text("B1"), "1.32"); +} + +#[test] +fn fvschedule_argument_count() { + let mut model = new_empty_model(); + model._set("A1", "=FVSCHEDULE()"); + model._set("A2", "=FVSCHEDULE(1)"); + model._set("A3", "=FVSCHEDULE(1,1,1)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); +} + +#[test] +fn fvschedule_edge_cases() { + let mut model = new_empty_model(); + + // Test with zero principal + model._set("A1", "0"); + model._set("A2", "0.1"); + model._set("A3", "0.2"); + model._set("B1", "=FVSCHEDULE(A1, A2:A3)"); + + // Test with negative principal + model._set("C1", "-100"); + model._set("D1", "=FVSCHEDULE(C1, A2:A3)"); + + // Test with zero rates + model._set("E1", "100"); + model._set("E2", "0"); + model._set("E3", "0"); + model._set("F1", "=FVSCHEDULE(E1, E2:E3)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "0"); // 0 * anything = 0 + assert_eq!(model._get_text("D1"), "-132"); // -100 * 1.1 * 1.2 = -132 + assert_eq!(model._get_text("F1"), "100"); // 100 * 1 * 1 = 100 +} + +#[test] +fn fvschedule_rate_validation() { + let mut model = new_empty_model(); + + // Test with rate exactly -1 (should cause error due to validation in patch 1) + model._set("A1", "100"); + model._set("A2", "-1"); + model._set("A3", "0.1"); + model._set("B1", "=FVSCHEDULE(A1, A2:A3)"); + + // Test with rate less than -1 (should cause error) + model._set("C1", "100"); + model._set("C2", "-1.5"); + model._set("C3", "0.1"); + model._set("D1", "=FVSCHEDULE(C1, C2:C3)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "#NUM!"); + assert_eq!(model._get_text("D1"), "#NUM!"); +} diff --git a/docs/src/functions/financial.md b/docs/src/functions/financial.md index 606e01b..44ae7ef 100644 --- a/docs/src/functions/financial.md +++ b/docs/src/functions/financial.md @@ -31,7 +31,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | DURATION | | – | | EFFECT | | – | | FV | | [FV](financial/fv) | -| FVSCHEDULE | | – | +| FVSCHEDULE | | [FVSCHEDULE](financial/fvschedule) | | INTRATE | | – | | IPMT | | – | | IRR | | – | diff --git a/docs/src/functions/financial/fvschedule.md b/docs/src/functions/financial/fvschedule.md index 797ca7d..bb860a1 100644 --- a/docs/src/functions/financial/fvschedule.md +++ b/docs/src/functions/financial/fvschedule.md @@ -7,6 +7,5 @@ lang: en-US # FVSCHEDULE ::: 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