From dacf03d82d9a666abb771cbaafdacc82a77df5f7 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 30 Jul 2025 01:31:39 -0700 Subject: [PATCH] merge duration, mduration #44 --- .../src/expressions/parser/static_analysis.rs | 4 + base/src/functions/financial.rs | 108 ++++++ base/src/functions/mod.rs | 12 +- base/src/test/mod.rs | 1 + base/src/test/test_fn_duration.rs | 350 ++++++++++++++++++ base/src/test/test_fn_financial.rs | 1 + docs/src/functions/financial.md | 4 +- docs/src/functions/financial/duration.md | 3 +- docs/src/functions/financial/mduration.md | 3 +- 9 files changed, 479 insertions(+), 7 deletions(-) create mode 100644 base/src/test/test_fn_duration.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 6253d82..6784981 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -759,6 +759,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 2, 0), Function::Nper => args_signature_scalars(arg_count, 3, 2), Function::Npv => args_signature_npv(arg_count), + Function::Duration => args_signature_scalars(arg_count, 5, 1), + Function::Mduration => args_signature_scalars(arg_count, 5, 1), Function::Pduration => 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), @@ -1023,6 +1025,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Nominal => not_implemented(args), Function::Nper => not_implemented(args), Function::Npv => not_implemented(args), + Function::Duration => not_implemented(args), + Function::Mduration => not_implemented(args), Function::Pduration => not_implemented(args), Function::Pmt => not_implemented(args), Function::Ppmt => not_implemented(args), diff --git a/base/src/functions/financial.rs b/base/src/functions/financial.rs index 8e11ee4..9f588ae 100644 --- a/base/src/functions/financial.rs +++ b/base/src/functions/financial.rs @@ -1339,6 +1339,114 @@ impl Model { CalcResult::Number(result) } + // DURATION(settlement, maturity, coupon, yld, freq, [basis]) + pub(crate) fn fn_duration(&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 coupon = 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 freq = match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + }; + let basis = if arg_count > 5 { + match self.get_number_no_bools(&args[5], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + } + } else { + 0 + }; + if settlement >= maturity || coupon < 0.0 || yld < 0.0 || !matches!(freq, 1 | 2 | 4) { + return CalcResult::new_error(Error::NUM, cell, "Invalid arguments".to_string()); + } + + let days_in_year = match basis { + 0 | 2 | 4 => 360.0, + 1 | 3 => 365.0, + _ => 360.0, + }; + let diff_days = maturity - settlement; + if diff_days <= 0.0 { + return CalcResult::new_error(Error::NUM, cell, "Invalid arguments".to_string()); + } + let yearfrac = diff_days / days_in_year; + let mut num_coupons = (yearfrac * freq as f64).ceil(); + if num_coupons < 1.0 { + num_coupons = 1.0; + } + + let cf = coupon * 100.0 / freq as f64; + let y = 1.0 + yld / freq as f64; + let ndiff = yearfrac * freq as f64 - num_coupons; + let mut dur = 0.0; + for t in 1..(num_coupons as i32) { + let tt = t as f64 + ndiff; + dur += tt * cf / y.powf(tt); + } + let last_t = num_coupons + ndiff; + dur += last_t * (cf + 100.0) / y.powf(last_t); + + let mut price = 0.0; + for t in 1..(num_coupons as i32) { + let tt = t as f64 + ndiff; + price += cf / y.powf(tt); + } + price += (cf + 100.0) / y.powf(last_t); + + if price == 0.0 { + return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string()); + } + + let result = (dur / price) / freq as f64; + CalcResult::Number(result) + } + + // MDURATION(settlement, maturity, coupon, yld, freq, [basis]) + pub(crate) fn fn_mduration(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let mut res = self.fn_duration(args, cell); + if let CalcResult::Number(ref mut d) = res { + let yld = match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f, + Err(_) => { + return CalcResult::new_error( + Error::VALUE, + cell, + "Invalid arguments".to_string(), + ) + } + }; + let freq = match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f.trunc(), + Err(_) => { + return CalcResult::new_error( + Error::VALUE, + cell, + "Invalid arguments".to_string(), + ) + } + }; + *d /= 1.0 + yld / freq; + } + res + } + // This next three functions deal with Treasure Bills or T-Bills for short // They are zero-coupon that mature in one year or less. // Definitions: diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 53a2868..a59937e 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -232,6 +232,8 @@ pub enum Function { Nominal, Nper, Npv, + Duration, + Mduration, Pduration, Pmt, Ppmt, @@ -313,7 +315,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -497,6 +499,8 @@ impl Function { Function::Syd, Function::Nominal, Function::Effect, + Function::Duration, + Function::Mduration, Function::Pduration, Function::Tbillyield, Function::Tbillprice, @@ -832,6 +836,8 @@ impl Function { "SYD" => Some(Function::Syd), "NOMINAL" => Some(Function::Nominal), "EFFECT" => Some(Function::Effect), + "DURATION" => Some(Function::Duration), + "MDURATION" => Some(Function::Mduration), "PDURATION" | "_XLFN.PDURATION" => Some(Function::Pduration), "TBILLYIELD" => Some(Function::Tbillyield), @@ -1075,6 +1081,8 @@ impl fmt::Display for Function { Function::Syd => write!(f, "SYD"), Function::Nominal => write!(f, "NOMINAL"), Function::Effect => write!(f, "EFFECT"), + Function::Duration => write!(f, "DURATION"), + Function::Mduration => write!(f, "MDURATION"), Function::Pduration => write!(f, "PDURATION"), Function::Tbillyield => write!(f, "TBILLYIELD"), Function::Tbillprice => write!(f, "TBILLPRICE"), @@ -1352,6 +1360,8 @@ impl Model { Function::Syd => self.fn_syd(args, cell), Function::Nominal => self.fn_nominal(args, cell), Function::Effect => self.fn_effect(args, cell), + Function::Duration => self.fn_duration(args, cell), + Function::Mduration => self.fn_mduration(args, cell), Function::Pduration => self.fn_pduration(args, cell), Function::Tbillyield => self.fn_tbillyield(args, cell), Function::Tbillprice => self.fn_tbillprice(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index f48604f..55cb702 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -17,6 +17,7 @@ mod test_fn_choose; mod test_fn_concatenate; mod test_fn_count; mod test_fn_day; +mod test_fn_duration; mod test_fn_exact; mod test_fn_financial; mod test_fn_formulatext; diff --git a/base/src/test/test_fn_duration.rs b/base/src/test/test_fn_duration.rs new file mode 100644 index 0000000..14accbc --- /dev/null +++ b/base/src/test/test_fn_duration.rs @@ -0,0 +1,350 @@ +#![allow(clippy::unwrap_used)] +#![allow(clippy::panic)] + +use crate::{cell::CellValue, test::util::new_empty_model}; + +// Test constants for realistic bond scenarios +const BOND_SETTLEMENT: &str = "=DATE(2020,1,1)"; +const BOND_MATURITY_4Y: &str = "=DATE(2024,1,1)"; +const BOND_MATURITY_INVALID: &str = "=DATE(2016,1,1)"; // Before settlement +const BOND_MATURITY_SAME: &str = "=DATE(2020,1,1)"; // Same as settlement +const BOND_MATURITY_1DAY: &str = "=DATE(2020,1,2)"; // Very short term + +// Standard investment-grade corporate bond parameters +const STD_COUPON: f64 = 0.08; // 8% annual coupon rate +const STD_YIELD: f64 = 0.09; // 9% yield (discount bond scenario) +const STD_FREQUENCY: i32 = 2; // Semi-annual payments (most common) + +// Helper function to reduce test repetition +fn assert_numerical_result(model: &crate::Model, cell_ref: &str, should_be_positive: bool) { + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref(cell_ref) { + if should_be_positive { + assert!(v > 0.0, "Expected positive value at {cell_ref}, got {v}"); + } + // Value is valid - test passes + } else { + panic!("Expected numerical result at {cell_ref}"); + } +} + +#[test] +fn fn_duration_mduration_arguments() { + let mut model = new_empty_model(); + + // Test argument count validation + model._set("A1", "=DURATION()"); + model._set("A2", "=DURATION(1,2,3,4)"); + model._set("A3", "=DURATION(1,2,3,4,5,6,7)"); + + model._set("B1", "=MDURATION()"); + model._set("B2", "=MDURATION(1,2,3,4)"); + model._set("B3", "=MDURATION(1,2,3,4,5,6,7)"); + + model.evaluate(); + + // Too few or too many arguments should result in errors + 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_duration_mduration_settlement_maturity_errors() { + let mut model = new_empty_model(); + + model._set("A1", BOND_SETTLEMENT); + model._set("A2", BOND_MATURITY_INVALID); // Before settlement + model._set("A3", BOND_MATURITY_SAME); // Same as settlement + + // Both settlement > maturity and settlement = maturity should error + model._set( + "B1", + &format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"), + ); + model._set( + "B2", + &format!("=DURATION(A1,A3,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"), + ); + model._set( + "B3", + &format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"), + ); + model._set( + "B4", + &format!("=MDURATION(A1,A3,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"), + ); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("B3"), *"#NUM!"); + assert_eq!(model._get_text("B4"), *"#NUM!"); +} + +#[test] +fn fn_duration_mduration_negative_values_errors() { + let mut model = new_empty_model(); + + model._set("A1", BOND_SETTLEMENT); + model._set("A2", BOND_MATURITY_4Y); + + // Test negative coupon (coupons must be >= 0) + model._set( + "B1", + &format!("=DURATION(A1,A2,-0.01,{STD_YIELD},{STD_FREQUENCY})"), + ); + model._set( + "B2", + &format!("=MDURATION(A1,A2,-0.01,{STD_YIELD},{STD_FREQUENCY})"), + ); + + // Test negative yield (yields must be >= 0) + model._set( + "C1", + &format!("=DURATION(A1,A2,{STD_COUPON},-0.01,{STD_FREQUENCY})"), + ); + model._set( + "C2", + &format!("=MDURATION(A1,A2,{STD_COUPON},-0.01,{STD_FREQUENCY})"), + ); + + 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_duration_mduration_invalid_frequency_errors() { + let mut model = new_empty_model(); + + model._set("A1", BOND_SETTLEMENT); + model._set("A2", BOND_MATURITY_4Y); + + // Only 1, 2, and 4 are valid frequencies (annual, semi-annual, quarterly) + let invalid_frequencies = [0, 3, 5, 12]; // Common invalid values + + for (i, &freq) in invalid_frequencies.iter().enumerate() { + let row = i + 1; + model._set( + &format!("B{row}"), + &format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{freq})"), + ); + model._set( + &format!("C{row}"), + &format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{freq})"), + ); + } + + model.evaluate(); + + for i in 1..=invalid_frequencies.len() { + assert_eq!(model._get_text(&format!("B{i}")), *"#NUM!"); + assert_eq!(model._get_text(&format!("C{i}")), *"#NUM!"); + } +} + +#[test] +fn fn_duration_mduration_frequency_variations() { + let mut model = new_empty_model(); + model._set("A1", BOND_SETTLEMENT); + model._set("A2", BOND_MATURITY_4Y); + + // Test all valid frequencies: 1=annual, 2=semi-annual, 4=quarterly + let valid_frequencies = [1, 2, 4]; + + for (i, &freq) in valid_frequencies.iter().enumerate() { + let row = i + 1; + model._set( + &format!("B{row}"), + &format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{freq})"), + ); + model._set( + &format!("C{row}"), + &format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{freq})"), + ); + } + + model.evaluate(); + + // All should return positive numerical values + for i in 1..=valid_frequencies.len() { + assert_numerical_result(&model, &format!("Sheet1!B{i}"), true); + assert_numerical_result(&model, &format!("Sheet1!C{i}"), true); + } +} + +#[test] +fn fn_duration_mduration_basis_variations() { + let mut model = new_empty_model(); + model._set("A1", BOND_SETTLEMENT); + model._set("A2", BOND_MATURITY_4Y); + + // Test all valid basis values (day count conventions) + // 0=30/360 US, 1=Actual/actual, 2=Actual/360, 3=Actual/365, 4=30/360 European + for basis in 0..=4 { + let row = basis + 1; + model._set( + &format!("B{row}"), + &format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY},{basis})"), + ); + model._set( + &format!("C{row}"), + &format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY},{basis})"), + ); + } + + // Test default basis (should be 0) + model._set( + "D1", + &format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"), + ); + model._set( + "D2", + &format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"), + ); + + model.evaluate(); + + // All basis values should work + for row in 1..=5 { + assert_numerical_result(&model, &format!("Sheet1!B{row}"), true); + assert_numerical_result(&model, &format!("Sheet1!C{row}"), true); + } + + // Default basis should match basis 0 + if let (Ok(CellValue::Number(d1)), Ok(CellValue::Number(b1))) = ( + model.get_cell_value_by_ref("Sheet1!D1"), + model.get_cell_value_by_ref("Sheet1!B1"), + ) { + assert!( + (d1 - b1).abs() < 1e-10, + "Default basis should match basis 0" + ); + } +} + +#[test] +fn fn_duration_mduration_edge_cases() { + let mut model = new_empty_model(); + + model._set("A1", BOND_SETTLEMENT); + model._set("A2", BOND_MATURITY_1DAY); // Very short term (1 day) + model._set("A3", BOND_MATURITY_4Y); // Standard term + + // Edge case scenarios with explanations + let test_cases = [ + ("B", "A1", "A2", STD_COUPON, STD_YIELD, "short_term"), // 1-day bond + ("C", "A1", "A3", 0.0, STD_YIELD, "zero_coupon"), // Zero coupon bond + ("D", "A1", "A3", STD_COUPON, 0.0, "zero_yield"), // Zero yield + ("E", "A1", "A3", 1.0, 0.5, "high_rates"), // High coupon/yield (100%/50%) + ]; + + for (col, settlement, maturity, coupon, yield_rate, _scenario) in test_cases { + model._set( + &format!("{col}1"), + &format!("=DURATION({settlement},{maturity},{coupon},{yield_rate},{STD_FREQUENCY})"), + ); + model._set( + &format!("{col}2"), + &format!("=MDURATION({settlement},{maturity},{coupon},{yield_rate},{STD_FREQUENCY})"), + ); + } + + model.evaluate(); + + // All edge cases should return positive values + for col in ["B", "C", "D", "E"] { + assert_numerical_result(&model, &format!("Sheet1!{col}1"), true); + assert_numerical_result(&model, &format!("Sheet1!{col}2"), true); + } +} + +#[test] +fn fn_duration_mduration_relationship() { + let mut model = new_empty_model(); + model._set("A1", BOND_SETTLEMENT); + model._set("A2", BOND_MATURITY_4Y); + + // Test mathematical relationship: MDURATION = DURATION / (1 + yield/frequency) + model._set( + "B1", + &format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"), + ); + model._set( + "B2", + &format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"), + ); + model._set("B3", &format!("=B1/(1+{STD_YIELD}/{STD_FREQUENCY})")); // Manual calculation + + // Test with quarterly frequency and different yield + model._set("C1", &format!("=DURATION(A1,A2,{STD_COUPON},0.12,4)")); + model._set("C2", &format!("=MDURATION(A1,A2,{STD_COUPON},0.12,4)")); + model._set("C3", "=C1/(1+0.12/4)"); // Manual calculation for quarterly + + model.evaluate(); + + // MDURATION should equal DURATION / (1 + yield/frequency) for both scenarios + if let (Ok(CellValue::Number(md)), Ok(CellValue::Number(manual))) = ( + model.get_cell_value_by_ref("Sheet1!B2"), + model.get_cell_value_by_ref("Sheet1!B3"), + ) { + assert!( + (md - manual).abs() < 1e-10, + "MDURATION should equal DURATION/(1+yield/freq)" + ); + } + + if let (Ok(CellValue::Number(md)), Ok(CellValue::Number(manual))) = ( + model.get_cell_value_by_ref("Sheet1!C2"), + model.get_cell_value_by_ref("Sheet1!C3"), + ) { + assert!( + (md - manual).abs() < 1e-10, + "MDURATION should equal DURATION/(1+yield/freq) for quarterly" + ); + } +} + +#[test] +fn fn_duration_mduration_regression() { + // Original regression test with known expected values + let mut model = new_empty_model(); + model._set("A1", "=DATE(2016,1,1)"); + model._set("A2", "=DATE(2020,1,1)"); + model._set( + "B1", + &format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"), + ); + model._set( + "B2", + &format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"), + ); + + model.evaluate(); + + // Verify exact values for regression testing + if let Ok(CellValue::Number(v1)) = model.get_cell_value_by_ref("Sheet1!B1") { + assert!( + (v1 - 3.410746844012284).abs() < 1e-9, + "DURATION regression test failed" + ); + } else { + panic!("Unexpected value for DURATION"); + } + if let Ok(CellValue::Number(v2)) = model.get_cell_value_by_ref("Sheet1!B2") { + assert!( + (v2 - 3.263872578002186).abs() < 1e-9, + "MDURATION regression test failed" + ); + } else { + panic!("Unexpected value for MDURATION"); + } +} diff --git a/base/src/test/test_fn_financial.rs b/base/src/test/test_fn_financial.rs index a5f31f0..fb8c3a2 100644 --- a/base/src/test/test_fn_financial.rs +++ b/base/src/test/test_fn_financial.rs @@ -1,4 +1,5 @@ #![allow(clippy::unwrap_used)] +#![allow(clippy::panic)] use crate::{cell::CellValue, test::util::new_empty_model}; diff --git a/docs/src/functions/financial.md b/docs/src/functions/financial.md index e8593cd..b666396 100644 --- a/docs/src/functions/financial.md +++ b/docs/src/functions/financial.md @@ -28,7 +28,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | DISC | | – | | DOLLARDE | | – | | DOLLARFR | | – | -| DURATION | | – | +| DURATION | | – | | EFFECT | | – | | FV | | [FV](financial/fv) | | FVSCHEDULE | | – | @@ -36,7 +36,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | IPMT | | – | | IRR | | – | | ISPMT | | – | -| MDURATION | | – | +| MDURATION | | – | | MIRR | | – | | NOMINAL | | – | | NPER | | – | diff --git a/docs/src/functions/financial/duration.md b/docs/src/functions/financial/duration.md index d163aa0..6b86251 100644 --- a/docs/src/functions/financial/duration.md +++ b/docs/src/functions/financial/duration.md @@ -7,6 +7,5 @@ lang: en-US # DURATION ::: 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/mduration.md b/docs/src/functions/financial/mduration.md index efe28f1..49fee2c 100644 --- a/docs/src/functions/financial/mduration.md +++ b/docs/src/functions/financial/mduration.md @@ -7,6 +7,5 @@ lang: en-US # MDURATION ::: 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